From 25508c69ad47019d3ce0f3efc7b243ce1d6dcf8a Mon Sep 17 00:00:00 2001 From: LuRy Date: Sun, 14 Jun 2026 01:04:18 +0200 Subject: [PATCH 01/49] feat(network): add disconnectPeer + isBootstrapOrRelayPeer helpers --- backend/src/protocol/network.ts | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 74be6412..dd85f1ba 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -1016,6 +1016,68 @@ export class Network { } } + /** + * True if the peer is one we must never voluntarily disconnect because it + * provides infrastructure rather than being a plain content peer: a + * configured/known bootstrap peer, or a peer we are currently reaching over a + * circuit-relay connection (dropping the relay would also kill transit for + * any NAT'd siblings reachable only through it). + * + * Used by lishnet leave to decide which topic peers are safe to hang up — + * leaving an empty lishnet must not tear down shared bootstrap/relay links + * that other still-joined lishnets depend on. + */ + isBootstrapOrRelayPeer(peerID: string): boolean { + if (this.bootstrapPeerIDs.has(peerID)) return true; + if (!this.node) return false; + try { + const conns = this.node.getConnections(peerIDFromString(peerID)); + return conns.some(c => Circuit.matches(c.remoteAddr)); + } catch { + return false; + } + } + + /** + * Gracefully disconnect from a single peer and stop libp2p from immediately + * re-dialing it. This is the ONLY place that should call `node.hangUp()` so + * the accompanying ReconnectQueue cleanup (removing the `keep-alive-fleet` + * tag) is never forgotten — without dropping that tag, `peer:discovery` / + * ReconnectQueue would re-dial the peer within seconds and the disconnect + * would be pointless. + * + * Unlike {@link purgeStalePeer} this does NOT delete the peerStore entry: the + * peer is legitimate (we just no longer have a reason to stay connected after + * leaving its lishnet), so we keep its addresses cached for cheap re-dial if + * the user re-joins. Best-effort: failures are logged at trace, never thrown. + */ + async disconnectPeer(peerID: string): Promise { + if (!this.node) return; + let pid: PeerID; + try { + pid = peerIDFromString(peerID); + } catch (err: any) { + trace(`[NET] disconnectPeer: invalid peerID ${peerID.slice(0, 16)}: ${err?.message ?? err}`); + return; + } + // Remove the fleet keep-alive tag FIRST so the imminent hangUp does not + // race the ReconnectQueue back into a re-dial. Passing undefined as the + // tag value removes it (per @libp2p/interface PeerStore merge semantics). + try { + await this.node.peerStore.merge(pid, { + tags: { 'keep-alive-fleet': undefined }, + }); + } catch (err: any) { + trace(`[NET] disconnectPeer: tag removal failed for ${peerID.slice(0, 16)}: ${err?.message ?? err}`); + } + try { + await this.node.hangUp(pid); + trace(`[NET] disconnectPeer: hung up ${peerID.slice(0, 16)}`); + } catch (err: any) { + trace(`[NET] disconnectPeer: hangUp failed for ${peerID.slice(0, 16)}: ${err?.message ?? err}`); + } + } + /** Snapshot of all per-network bootstrap statuses. */ getAllBootstrapStatuses(): BootstrapStatus[] { return this.bootstrapTracker.getAllStatuses(); From d043cb69694a8bf09f702b627f8a62cdf0b6a14c Mon Sep 17 00:00:00 2001 From: LuRy Date: Sun, 14 Jun 2026 01:05:09 +0200 Subject: [PATCH 02/49] feat(lishnet): disconnect lishnet-exclusive peers on leave + onNetworkLeft --- backend/src/lishnet/lishnets.ts | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index e57a0a77..facfaf67 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -21,6 +21,9 @@ export class Networks { private _onPeerCountChange: ((counts: { networkID: string; count: number }[]) => void) | null = null; // Callback for bootstrap status changes private _onBootstrapStatusChange: ((networkID: string, status: BootstrapStatus) => void) | null = null; + // Callback fired after a lishnet is left (topic unsubscribed). Lets higher + // layers (e.g. transfer) stop downloads bound exclusively to that lishnet. + private _onNetworkLeft: ((networkID: string) => void) | null = null; constructor(db: Database, dataDir: string, dataServer: DataServer, settings: Settings) { this.db = db; @@ -50,6 +53,16 @@ export class Networks { this._onBootstrapStatusChange = cb; } + /** + * Set a callback fired right after a lishnet is left (its topic has been + * unsubscribed and removed from {@link joinedNetworks}). The callback runs + * synchronously from {@link leaveNetwork}; consumers should not assume any + * particular peer/connection state beyond "this lishnet is no longer joined". + */ + set onNetworkLeft(cb: ((networkID: string) => void) | null) { + this._onNetworkLeft = cb; + } + init(): void { console.log('✓ Networks initialized'); } @@ -133,11 +146,36 @@ export class Networks { private async leaveNetwork(id: string): Promise { if (!this.joinedNetworks.has(id)) return; + // Snapshot the topic subscribers BEFORE unsubscribing — unsubscribeTopic + // tears the topic out of pubsub, after which getTopicPeers(id) returns []. + const leftPeers = this.network.getTopicPeers(id); + this.network.unsubscribeTopic(id); this.joinedNetworks.delete(id); + // Disconnect peers that belonged exclusively to the lishnet we just left. + // A peer is kept connected if it is still a subscriber of any OTHER joined + // lishnet, or if it is a bootstrap/relay peer (shared infrastructure other + // networks depend on). Everything else is a plain content peer with no + // remaining reason to stay connected, so hang it up via the single + // Network.disconnectPeer entry point (which also clears the keep-alive tag + // so ReconnectQueue does not immediately re-dial it). + const stillJoinedPeers = new Set(); + for (const otherID of this.joinedNetworks) { + for (const pid of this.network.getTopicPeers(otherID)) stillJoinedPeers.add(pid); + } + for (const pid of leftPeers) { + if (stillJoinedPeers.has(pid)) continue; + if (this.network.isBootstrapOrRelayPeer(pid)) continue; + await this.network.disconnectPeer(pid); + } + const net = this.get(id); console.log(`✓ Left lishnet: ${net?.name ?? id}`); + + // Notify higher layers (e.g. transfer) so downloads bound exclusively to + // this lishnet can be stopped. + this._onNetworkLeft?.(id); } /** From a1d3690ccbb641a08d2ba97fcb606ed19f16f3d8 Mon Sep 17 00:00:00 2001 From: LuRy Date: Sun, 14 Jun 2026 01:05:51 +0200 Subject: [PATCH 03/49] feat(transfer): disable downloads whose last joined lishnet was left --- backend/src/api/transfer.ts | 15 +++++++++++++++ backend/src/protocol/downloader.ts | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 4c142e90..e03843da 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -115,6 +115,21 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, const activeDownloaders = new Map(); setActiveDownloadersRef(activeDownloaders); + // When a lishnet is left, stop any download bound EXCLUSIVELY to it: a + // downloader keeps running as long as at least one of its networks is still + // joined (multi-network downloads can still source chunks elsewhere). Only + // when none of its networks remain joined is there no peer source left, so we + // disable it (leaving DB/enabled flags untouched — a re-join can resume it). + networks.onNetworkLeft = (networkID: string) => { + for (const [lishID, dl] of activeDownloaders) { + const ids = dl.getNetworkIDs?.() ?? []; + if (!ids.includes(networkID)) continue; + if (ids.some(id => networks.isJoined(id))) continue; + console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); + dl.disable(); + } + }; + // Error recovery: auto-retry when IO conditions clear const recovery = new ErrorRecovery({ attemptRecover: async (lishID, downloadWasEnabled, uploadWasEnabled): Promise => { diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index 616a1681..c9eafbc5 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -108,6 +108,15 @@ export class Downloader { return this.lishID; } + /** + * The lishnet network IDs this download is bound to (the networks across + * which it searches for and dials peers). Returned as a defensive copy so + * callers cannot mutate the downloader's internal list. + */ + getNetworkIDs(): string[] { + return [...this.networkIDs]; + } + /** * Central state mutation. Validates the requested transition against ALLOWED_TRANSITIONS * and logs a warning (and bails) if the transition is not allowed. From 898d49466e20139337ea8dd3d8da3c6fdfeb3578 Mon Sep 17 00:00:00 2001 From: LuRy Date: Sun, 14 Jun 2026 01:08:33 +0200 Subject: [PATCH 04/49] feat(downloader): drop peer from peer manager on network peer:disconnect --- backend/src/protocol/downloader.ts | 30 +++++++++++++++++++++++++ backend/src/protocol/network.ts | 35 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index c9eafbc5..ffb90d4f 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -77,6 +77,8 @@ export class Downloader { */ private peerDiscoveryTimer: ReturnType | undefined; private retryTimer: ReturnType | undefined; + // Disposer for the network `peer:disconnect` subscription; called in destroy(). + private peerDisconnectDisposer: (() => void) | undefined; private needsManifest = false; private disabled = false; private destroyed = false; @@ -268,6 +270,8 @@ export class Downloader { this.abortController.abort(); this.clearRetryTimer(); this.clearPeerDiscoveryTimer(); + this.peerDisconnectDisposer?.(); + this.peerDisconnectDisposer = undefined; if (this.lishID) unregisterHaveAnnouncementHandler(this.lishID); await this.peerManager.closeAllAwait('destroy'); // Notify frontend to reset peers/speed immediately @@ -292,6 +296,30 @@ export class Downloader { }); } + /** + * Subscribe to network-wide peer disconnects so a vanished peer is removed + * from our per-LISH peer manager immediately, rather than lingering until the + * next probe/dial fails. Idempotent — a stale subscription is disposed first. + * The disposer is released in {@link destroy}. + */ + private registerPeerDisconnectHandler(): void { + this.peerDisconnectDisposer?.(); + this.peerDisconnectDisposer = this.network.onPeerDisconnect(peerID => this.dropPeer(peerID)); + } + + /** + * Remove a peer from this download's peer manager because the underlying + * libp2p connection dropped. Plain 'disconnect' disposition (not a punitive + * drop/ban) — the peer may reconnect and be re-discovered normally. No-op if + * the peer is not currently a member. + */ + dropPeer(peerID: string): void { + if (this.destroyed) return; + if (!this.peerManager.has(peerID)) return; + trace(`[DL] ${this.lishID?.slice(0, 8) ?? '?'}: dropping disconnected peer ${peerID.slice(0, 12)}`); + this.peerManager.remove(peerID, 'disconnect'); + } + constructor(downloadDir: string, network: Network, dataServer: DataServer, networkIDs: string | string[]) { this.downloadDir = downloadDir; this.network = network; @@ -311,6 +339,7 @@ export class Downloader { console.log(`[DL] Loading LISH: ${this.lish.name} (${this.lishID.slice(0, 8)}), ${this.dataServer.getMissingChunks(this.lishID).length} chunks to download`); this.missingChunks = this.dataServer.getMissingChunks(this.lishID); this.registerAnnouncementHandler(); + this.registerPeerDisconnectHandler(); this.transitionTo('initialized', 'init() done'); } @@ -330,6 +359,7 @@ export class Downloader { console.log(`[DL] Loading LISH: ${this.lish.name} (${this.lishID.slice(0, 8)}), awaiting manifest from peer`); } this.registerAnnouncementHandler(); + this.registerPeerDisconnectHandler(); this.transitionTo('initialized', 'initFromManifest() done'); } diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index dd85f1ba..39a2ae07 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -266,6 +266,41 @@ export class Network { }; } + /** + * Subscribe to libp2p `peer:disconnect` events for the duration of the + * returned disposer. The handler receives the disconnected peer's ID as a + * string. + * + * Registered via {@link addListener} (memory hygiene — never call + * `addEventListener` directly) so a forgotten disposer is still cleaned up by + * {@link stop}. The returned disposer removes the listener from the tracked + * list so short-lived subscribers (e.g. a Downloader) do not leak across + * their own lifecycle. Used by downloads to drop a vanished peer from their + * per-LISH peer manager immediately, instead of waiting for the next failed + * dial/probe to notice the dead connection. + */ + onPeerDisconnect(handler: (peerID: string) => void): () => void { + if (!this.node) return () => {}; + const node = this.node; + const listener = (evt: any): void => { + const pid = evt.detail?.toString?.(); + if (pid) handler(pid); + }; + this.addListener(node, 'peer:disconnect', listener); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + try { + node.removeEventListener('peer:disconnect', listener as any); + } catch { + // Node may already be stopped — stop() walked the tracked list already. + } + const idx = this.listeners.findIndex(l => l.target === node && l.event === 'peer:disconnect' && l.handler === listener); + if (idx >= 0) this.listeners.splice(idx, 1); + }; + } + /** * Schedule a debounced check of peer counts for all subscribed topics. */ From 2228b8c5038b86fa571add497940a359ffe6681b Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:33:25 +0200 Subject: [PATCH 05/49] docs(protocol): mark out-of-scope TODOs (chunk AbortSignal, per-network ACL) --- backend/src/protocol/lish-handlers.ts | 10 ++++++++++ backend/src/protocol/lish-protocol.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/backend/src/protocol/lish-handlers.ts b/backend/src/protocol/lish-handlers.ts index 10dcb75c..61bf8a4f 100644 --- a/backend/src/protocol/lish-handlers.ts +++ b/backend/src/protocol/lish-handlers.ts @@ -52,6 +52,12 @@ export class LISHServingHandlers { /** Handle a `want` pubsub message from a remote peer requesting chunk metadata. */ async handleWant(data: WantMessage, networkID: string, fromPeerID?: string): Promise { + // TODO(out of scope): enforce per-network ACL here — only answer a + // WANT if `data.lishID` is actually shared into `networkID`. Today we + // answer based solely on global upload-enabled state, so a peer on ANY + // joined lishnet can pull any LISH we upload. Implementing this needs a + // LISH↔networkIDs mapping in the DB which does not yet exist; until then + // the topic membership is the only (coarse) access boundary. if (!fromPeerID) { trace(`[NET] want ignored: no verified sender peerID`); return; @@ -121,6 +127,10 @@ export class LISHServingHandlers { * (same query can hit the same node from several peering paths). */ async handleSearchLishs(data: SearchLishsMessage, networkID: string, fromPeerID?: string): Promise { + // TODO(out of scope): scope search results to LISHs actually shared + // into `networkID` (hence the explicit `void networkID` below — the param + // is received but not yet used for filtering). Blocked on the same missing + // LISH↔networkIDs DB mapping as handleWant's ACL TODO. void networkID; if (!fromPeerID) { trace(`[NET] searchLishs ignored: no verified sender peerID`); diff --git a/backend/src/protocol/lish-protocol.ts b/backend/src/protocol/lish-protocol.ts index 5bb552cd..ae701127 100644 --- a/backend/src/protocol/lish-protocol.ts +++ b/backend/src/protocol/lish-protocol.ts @@ -187,6 +187,11 @@ export class LISHClient { } // Request a single chunk (can be called multiple times on same stream) + // TODO(out of scope): thread an AbortSignal through requestChunk so a + // download disabled mid-flight (e.g. by leaving its last lishnet) can cancel + // an in-progress chunk read immediately instead of waiting for the stream + // read to complete or time out. Requires plumbing the downloader's + // abortController.signal down through ChunkDownloader → LISHClient. async requestChunk(lishID: LISHid, chunkID: ChunkID): Promise { // Bail early if stream is already closed/aborted — treat as transient (peer unreachable), // not as a reason to permanently ban the peer. From fc1362c87d8c7a8ed305d97ee9f3e04984d42dad Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:35:08 +0200 Subject: [PATCH 06/49] fix(protocol): gate unicast LISH discovery behind shared joined lishnet --- backend/src/protocol/lish-protocol.ts | 16 +++++++++--- backend/src/protocol/network.ts | 25 ++++++++++++++++++- .../unit/protocol/search-visibility.test.ts | 7 +++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/backend/src/protocol/lish-protocol.ts b/backend/src/protocol/lish-protocol.ts index ae701127..66947292 100644 --- a/backend/src/protocol/lish-protocol.ts +++ b/backend/src/protocol/lish-protocol.ts @@ -354,7 +354,7 @@ export function clearAllUploads(): void { const IO_ERROR_THRESHOLD = 3; // consecutive I/O errors before auto-disabling upload -export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, remotePeerID?: string, connectionType?: ConnectionType): Promise { +export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, remotePeerID?: string, connectionType?: ConnectionType, sharesNetworkWith?: (peerID: string) => boolean): Promise { const servedLishIDs = new Set(); const ioErrorCounts = new Map(); // per-LISH consecutive I/O error counter const remotePeer = remotePeerID?.slice(0, 12) ?? 'unknown'; @@ -393,6 +393,15 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, } if (request.type === 'getLishs') { + // Serve the shared-LISH list only to peers we share a joined + // lishnet with. A bare transport connection (e.g. the peer re-dialed + // us right after we left its network) must not reveal what we share. + if (sharesNetworkWith && remotePeerID && !sharesNetworkWith(remotePeerID)) { + trace(`[PROTO] getLishs from ${remotePeer} refused: no shared joined lishnet`); + const gated: LISHGetLishsResponse = { type: 'getLishs-result', lishs: [] }; + sendLengthPrefixed(stream, codecEncode(gated)); + continue; + } // Return list of all shared (upload_enabled) LISHs — id and name only. // Newest first — matches the order shown locally in "Download and Sharing". const allLishs = dataServer.list(); @@ -415,8 +424,9 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, }; sendLengthPrefixed(stream, codecEncode(response)); } else if (request.type === 'getLish') { - // Only return manifest for LISHs with upload enabled - if (!isUploadAdvertisable(request.lishID)) { + // Only return manifest for LISHs with upload enabled — and only to + // peers we share a joined lishnet with (same gate as getLishs). + if ((sharesNetworkWith && remotePeerID && !sharesNetworkWith(remotePeerID)) || !isUploadAdvertisable(request.lishID)) { const response: LISHGetLishResponse = { error: ErrorCodes.PEER_LISH_NOT_SHARED }; sendLengthPrefixed(stream, codecEncode(response)); } else { diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 39a2ae07..8c1afaf9 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -464,7 +464,7 @@ export class Network { } } const connType = remotePeerID ? classifyConnectionFn(remotePeerID, isRelay, this.dcutrPeers) : 'DIRECT'; - await handleLISHProtocol(stream, this.dataServer, remotePeerID, connType); + await handleLISHProtocol(stream, this.dataServer, remotePeerID, connType, pid => this.sharesJoinedTopicWith(pid)); } catch (err: any) { trace(`[NET] LISH handler error: ${err?.message ?? err}`); } @@ -1073,6 +1073,29 @@ export class Network { } } + /** + * True if we currently share at least one joined lishnet topic with the + * given peer — i.e. some lish topic WE are subscribed to lists the peer + * among its subscribers. + * + * Coarse serve-gate for unicast LISH discovery: a peer we no longer + * share any lishnet with must not be able to browse or search our shared + * LISHs just because a transport connection exists (e.g. the peer's + * keep-alive re-dialed us right after we left its network). + */ + sharesJoinedTopicWith(peerID: string): boolean { + if (!this.pubsub) return false; + for (const topic of this.pubsub.getTopics()) { + if (!topic.startsWith(LISH_TOPIC_PREFIX)) continue; + try { + if (this.pubsub.getSubscribers(topic).some((p: any) => p.toString() === peerID)) return true; + } catch { + // topic may be tearing down — treat as not shared + } + } + return false; + } + /** * Gracefully disconnect from a single peer and stop libp2p from immediately * re-dialing it. This is the ONLY place that should call `node.hangUp()` so diff --git a/backend/tests/unit/protocol/search-visibility.test.ts b/backend/tests/unit/protocol/search-visibility.test.ts index 9ec6b9ba..8d89bcf1 100644 --- a/backend/tests/unit/protocol/search-visibility.test.ts +++ b/backend/tests/unit/protocol/search-visibility.test.ts @@ -47,7 +47,12 @@ describe('LISH search visibility', () => { // exact substring that would break on every new predicate. const getLishsBlock = LISH_PROTOCOL_TS.slice(LISH_PROTOCOL_TS.indexOf("request.type === 'getLishs'"), LISH_PROTOCOL_TS.indexOf("request.type === 'getLish'")); expect(getLishsBlock).toContain('isUploadAdvertisable(l.id)'); - expect(LISH_PROTOCOL_TS).toContain('if (!isUploadAdvertisable(request.lishID))'); + // getLish guard: still rejects non-advertisable LISHs; the shared-lishnet + // gate sits in front of it within the same condition. + expect(LISH_PROTOCOL_TS).toContain('!isUploadAdvertisable(request.lishID)'); + // The lishnet serve-gate must protect both unicast discovery request types. + expect(getLishsBlock).toContain('sharesNetworkWith'); + expect(LISH_PROTOCOL_TS.slice(LISH_PROTOCOL_TS.indexOf("request.type === 'getLish'"))).toContain('sharesNetworkWith'); }); it('marks queued verification as busy before broadcasting pending-verification', () => { From 31616701dc1bcb90d82192238eefc9593ea67a5c Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 2 Jul 2026 19:07:53 +0200 Subject: [PATCH 07/49] fix(transfer): broadcast download:disabled on last-lishnet leave --- backend/src/api/transfer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index e03843da..600da92e 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -127,6 +127,9 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, if (ids.some(id => networks.isJoined(id))) continue; console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); dl.disable(); + // dl.disable() alone emits nothing over WS — tell the FE the download + // stopped. DB enabled flags stay untouched so a re-join can resume it. + broadcast?.('transfer.download:disabled', { lishID }); } }; From a93b3a9f661dc2150297515b8b71ace2fb56b956 Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:35:09 +0200 Subject: [PATCH 08/49] fix(protocol): extend lishnet serve-gate to getChunk requests --- backend/src/protocol/lish-protocol.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/protocol/lish-protocol.ts b/backend/src/protocol/lish-protocol.ts index 66947292..93082d1d 100644 --- a/backend/src/protocol/lish-protocol.ts +++ b/backend/src/protocol/lish-protocol.ts @@ -446,6 +446,16 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, } else if (request.type === 'getChunk' || request.type === undefined) { // Chunk request (type may be omitted for legacy compatibility) const chunkReq = request as LISHGetChunkRequest; + // Stop serving chunks to peers we no longer share a joined + // lishnet with. Without this a downloader re-dials our stored + // address after we left its network and the transfer silently + // continues over the fresh transport connection. + if (sharesNetworkWith && remotePeerID && !sharesNetworkWith(remotePeerID)) { + trace(`[PROTO] getChunk from ${remotePeer} refused: no shared joined lishnet`); + const gatedResponse: LISHGetChunkResponse = { error: ErrorCodes.PEER_LISH_NOT_SHARED }; + sendLengthPrefixed(stream, codecEncode(gatedResponse)); + continue; + } if (!uploadEnabled.has(chunkReq.lishID)) { const blockedResponse: LISHGetChunkResponse = { error: ErrorCodes.PEER_LISH_NOT_SHARED }; sendLengthPrefixed(stream, codecEncode(blockedResponse)); From a3b9c392fdd9d793d2c865aed96fa306668186b5 Mon Sep 17 00:00:00 2001 From: LuRy Date: Mon, 22 Jun 2026 19:03:51 +0200 Subject: [PATCH 09/49] test(downloader): add onPeerDisconnect to MockNetwork --- backend/tests/unit/helpers/mock-network.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/tests/unit/helpers/mock-network.ts b/backend/tests/unit/helpers/mock-network.ts index 571d279c..dcd7834f 100644 --- a/backend/tests/unit/helpers/mock-network.ts +++ b/backend/tests/unit/helpers/mock-network.ts @@ -31,6 +31,20 @@ export class MockNetwork { return []; } + /** Registered peer-disconnect handlers — invoke {@link emitPeerDisconnect} to simulate a peer dropping. */ + readonly peerDisconnectHandlers: Set<(peerID: string) => void> = new Set(); + + /** Mirrors Network.onPeerDisconnect: registers a handler and returns a disposer. */ + onPeerDisconnect(handler: (peerID: string) => void): () => void { + this.peerDisconnectHandlers.add(handler); + return () => this.peerDisconnectHandlers.delete(handler); + } + + /** Test helper: simulate a peer disconnect, invoking every registered handler. */ + emitPeerDisconnect(peerID: string): void { + for (const handler of this.peerDisconnectHandlers) handler(peerID); + } + isRunning(): boolean { return false; } From a83208cee54e62abd978f4f5e888821cbe2a92b7 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 2 Jul 2026 18:23:28 +0200 Subject: [PATCH 10/49] test(factory-reset): align peers default expectation with wipePeers=true --- backend/tests/unit/api/factory-reset-orchestrator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/unit/api/factory-reset-orchestrator.test.ts b/backend/tests/unit/api/factory-reset-orchestrator.test.ts index d1ddefea..ff84c255 100644 --- a/backend/tests/unit/api/factory-reset-orchestrator.test.ts +++ b/backend/tests/unit/api/factory-reset-orchestrator.test.ts @@ -276,7 +276,7 @@ describe('buildFactoryResetHandler — peers category', () => { expect(called).not.toContain('clearDatastore'); }); - it('peers defaults to false when no options are given (does not wipe by default)', async () => { + it('peers defaults to true when no options are given (wipes by default)', async () => { const called: string[] = []; const deps = makeDeps({ networkOverride: { @@ -289,7 +289,7 @@ describe('buildFactoryResetHandler — peers category', () => { const handler = buildFactoryResetHandler(deps); // Call with explicit all-true for the four original categories only await handler({ settings: true, identity: true, downloads: true, networks: true }); - expect(called).not.toContain('clearPeerstore'); + expect(called).toContain('clearPeerstore'); }); }); From 5744095fab6087d647d8f8a5e609f03780ec1e96 Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:35:31 +0200 Subject: [PATCH 11/49] test(downloader): cover peer:disconnect drop handling --- .../tests/unit/protocol/downloader.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/backend/tests/unit/protocol/downloader.test.ts b/backend/tests/unit/protocol/downloader.test.ts index ab65e80b..e135e98c 100644 --- a/backend/tests/unit/protocol/downloader.test.ts +++ b/backend/tests/unit/protocol/downloader.test.ts @@ -1279,3 +1279,65 @@ describe('Downloader — inline ENOSPC retry', () => { expect(pc.writeResolvers.length).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// Network peer:disconnect handling +// --------------------------------------------------------------------------- + +describe('Downloader – network peer:disconnect handling', () => { + type PeerManagerView = { + tryAdd: (peerID: string, client: unknown, connectionType: 'DIRECT' | 'RELAY' | 'DCUtR') => boolean; + has: (peerID: string) => boolean; + isDropped: (peerID: string) => boolean; + isBanned: (peerID: string) => boolean; + canDial: (peerID: string) => boolean; + }; + + let net: MockNetwork; + let downloader: Downloader; + + const pm = (): PeerManagerView => priv(downloader)['peerManager'] as PeerManagerView; + + beforeEach(async () => { + net = new MockNetwork(); + const ds = new MockDataServer(); + ds.missingChunks = []; + downloader = new Downloader('/tmp/dl', net as never, ds as never, 'net-001'); + await downloader.initFromManifest(makeLISH()); + }); + + afterEach(async () => { + await downloader.destroy(); + }); + + it('initFromManifest subscribes exactly one peer:disconnect handler', () => { + expect(net.peerDisconnectHandlers.size).toBe(1); + }); + + it('a disconnected peer is removed from the peer manager', () => { + pm().tryAdd('peer-gone', new MockLISHClient() as never, 'DIRECT'); + pm().tryAdd('peer-stays', new MockLISHClient() as never, 'DIRECT'); + net.emitPeerDisconnect('peer-gone'); + expect(pm().has('peer-gone')).toBe(false); + expect(pm().has('peer-stays')).toBe(true); + }); + + it('disconnect removal is plain — peer is neither dropped nor banned and may re-dial', () => { + pm().tryAdd('peer-flap', new MockLISHClient() as never, 'DIRECT'); + net.emitPeerDisconnect('peer-flap'); + expect(pm().isDropped('peer-flap')).toBe(false); + expect(pm().isBanned('peer-flap')).toBe(false); + expect(pm().canDial('peer-flap')).toBe(true); + }); + + it('disconnect of a peer not in the peer manager is a no-op', () => { + pm().tryAdd('peer-a', new MockLISHClient() as never, 'DIRECT'); + expect(() => net.emitPeerDisconnect('peer-unknown')).not.toThrow(); + expect(pm().has('peer-a')).toBe(true); + }); + + it('destroy() disposes the peer:disconnect subscription', async () => { + await downloader.destroy(); + expect(net.peerDisconnectHandlers.size).toBe(0); + }); +}); From 0010fb572956de8f41a395654f6a855f3151b61a Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:35:32 +0200 Subject: [PATCH 12/49] test(lishnet): cover leave-network exclusive peer disconnect --- .../tests/unit/lishnet/leave-network.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 backend/tests/unit/lishnet/leave-network.test.ts diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts new file mode 100644 index 00000000..915f3c96 --- /dev/null +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { Networks } from '../../../src/lishnet/lishnets.ts'; + +/** + * Unit tests for Networks.leaveNetwork peer-disconnect behaviour: + * on leaving a lishnet, peers that belonged exclusively to it are hung up, + * while peers shared with another joined lishnet and bootstrap/relay peers + * stay connected. Uses a bare instance (Object.create) so no real libp2p + * node or database is needed. + */ + +interface MockNet { + topicPeers: Map; + unsubscribed: string[]; + disconnected: string[]; + bootstrapOrRelay: Set; + getTopicPeers(id: string): string[]; + unsubscribeTopic(id: string): void; + isBootstrapOrRelayPeer(pid: string): boolean; + disconnectPeer(pid: string): Promise; +} + +function makeMockNet(): MockNet { + return { + topicPeers: new Map(), + unsubscribed: [], + disconnected: [], + bootstrapOrRelay: new Set(), + getTopicPeers(id) { + return this.topicPeers.get(id) ?? []; + }, + unsubscribeTopic(id) { + this.unsubscribed.push(id); + // Mirror real pubsub: after unsubscribe the topic reports no peers. + this.topicPeers.delete(id); + }, + isBootstrapOrRelayPeer(pid) { + return this.bootstrapOrRelay.has(pid); + }, + async disconnectPeer(pid) { + this.disconnected.push(pid); + }, + }; +} + +function makeNetworks(net: MockNet, joined: string[]): Networks { + const networks = Object.create(Networks.prototype) as Networks; + (networks as any).network = net; + (networks as any).joinedNetworks = new Set(joined); + (networks as any)._onNetworkLeft = null; + // leaveNetwork resolves the lishnet name only for logging — no DB here. + (networks as any).get = () => undefined; + return networks; +} + +const leave = (networks: Networks, id: string): Promise => (networks as any).leaveNetwork(id); + +describe('Networks.leaveNetwork — exclusive peer disconnect', () => { + let net: MockNet; + + beforeEach(() => { + net = makeMockNet(); + }); + + it('disconnects peers that were only in the left lishnet, keeps shared ones', async () => { + net.topicPeers.set('net-a', ['p-only-a', 'p-shared']); + net.topicPeers.set('net-b', ['p-shared']); + const networks = makeNetworks(net, ['net-a', 'net-b']); + await leave(networks, 'net-a'); + expect(net.disconnected).toEqual(['p-only-a']); + }); + + it('keeps bootstrap/relay peers even when exclusive to the left lishnet', async () => { + net.topicPeers.set('net-a', ['p-bootstrap', 'p-plain']); + net.bootstrapOrRelay.add('p-bootstrap'); + const networks = makeNetworks(net, ['net-a']); + await leave(networks, 'net-a'); + expect(net.disconnected).toEqual(['p-plain']); + }); + + it('snapshots topic peers before unsubscribing', async () => { + net.topicPeers.set('net-a', ['p1', 'p2']); + const networks = makeNetworks(net, ['net-a']); + await leave(networks, 'net-a'); + // The mock wipes the topic on unsubscribe — a post-unsubscribe read would + // have seen [] and disconnected nobody. + expect(net.unsubscribed).toEqual(['net-a']); + expect(net.disconnected).toEqual(['p1', 'p2']); + }); + + it('is a no-op for a lishnet that is not joined', async () => { + net.topicPeers.set('net-a', ['p1']); + const networks = makeNetworks(net, []); + let leftFired = 0; + networks.onNetworkLeft = () => leftFired++; + await leave(networks, 'net-a'); + expect(net.unsubscribed).toEqual([]); + expect(net.disconnected).toEqual([]); + expect(leftFired).toBe(0); + }); + + it('fires onNetworkLeft with the left lishnet id and un-joins it', async () => { + net.topicPeers.set('net-a', []); + const networks = makeNetworks(net, ['net-a']); + const leftIDs: string[] = []; + networks.onNetworkLeft = id => leftIDs.push(id); + await leave(networks, 'net-a'); + expect(leftIDs).toEqual(['net-a']); + expect((networks as any).joinedNetworks.has('net-a')).toBe(false); + }); +}); From 4a788621f4aa55ef1db1ba0d53258e2ee35f63fc Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 2 Jul 2026 19:07:53 +0200 Subject: [PATCH 13/49] test(e2e): add onPeerDisconnect to transfer-integration MockNetwork --- backend/tests/e2e/transfer-integration.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/tests/e2e/transfer-integration.test.ts b/backend/tests/e2e/transfer-integration.test.ts index b127b4bd..d6aa7ec9 100644 --- a/backend/tests/e2e/transfer-integration.test.ts +++ b/backend/tests/e2e/transfer-integration.test.ts @@ -109,6 +109,13 @@ class MockNetwork { subscribedTopics: Array<{ topic: string; handler: (data: Record) => void }> = []; broadcastMessages: Array<{ topic: string; data: Record }> = []; dialResults = new Map(); + /** Registered peer-disconnect handlers — mirrors Network.onPeerDisconnect. */ + readonly peerDisconnectHandlers: Set<(peerID: string) => void> = new Set(); + + onPeerDisconnect(handler: (peerID: string) => void): () => void { + this.peerDisconnectHandlers.add(handler); + return () => this.peerDisconnectHandlers.delete(handler); + } async subscribe(topic: string, handler: (data: Record) => void): Promise { this.subscribedTopics.push({ topic, handler }); From bba1741e3119920d6d031b821f2a624141a3e30b Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:35:51 +0200 Subject: [PATCH 14/49] test(dev): add live 2-node leave-network verification script --- backend/tests/dev/verify-leave-network.ts | 259 ++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 backend/tests/dev/verify-leave-network.ts diff --git a/backend/tests/dev/verify-leave-network.ts b/backend/tests/dev/verify-leave-network.ts new file mode 100644 index 00000000..793ca246 --- /dev/null +++ b/backend/tests/dev/verify-leave-network.ts @@ -0,0 +1,259 @@ +/** + * Live 2-node verification of the leave-lishnet disconnect behaviour. + * + * Scenario from the bug report: a node that shares content leaves the + * network, yet other nodes still find it via search and an in-flight + * download keeps running. After the fix, leaving must: + * 1. stop the leaver from answering searches of that lishnet, + * 2. starve any in-flight download sourced from the leaver (peer dropped), + * 3. disable a download whose LAST joined lishnet was left (downloader side). + * + * Run from the repo root: bun run backend/tests/dev/verify-leave-network.ts + * Exit code 0 = all checks passed. + */ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { TestClient } from '../e2e/helpers/ws-test-client.ts'; + +const REPO_ROOT = resolve(import.meta.dir, '../../..'); +const NET_ID = 'net-leave-verify'; +const LISH_NAME = 'lish-leave-verify'; +const WS_PORT_1 = 44700 + Math.floor(Math.random() * 100); +const WS_PORT_2 = WS_PORT_1 + 1; + +const results: Array<{ name: string; pass: boolean; note?: string }> = []; +function check(name: string, pass: boolean, note?: string): void { + results.push(note === undefined ? { name, pass } : { name, pass, note }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${note ? ` — ${note}` : ''}`); +} + +function sleep(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +async function poll(label: string, timeoutMs: number, intervalMs: number, fn: () => Promise): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const v = await fn(); + if (v !== undefined) return v; + } catch { + // endpoint may not be ready yet — keep polling + } + await sleep(intervalMs); + } + console.log(`[poll] ${label}: timed out after ${timeoutMs}ms`); + return undefined; +} + +const P2P_PORT_1 = WS_PORT_1 + 1000; +const P2P_PORT_2 = WS_PORT_2 + 1000; + +const tmp = mkdtempSync(join(tmpdir(), 'lish-leave-verify-')); +const dirs = { + node1: join(tmp, 'node1'), + node2: join(tmp, 'node2'), + share: join(tmp, 'share'), + dl2: join(tmp, 'dl2'), +}; +for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true }); +// Pre-seed partial settings (deep-merged over defaults on load): unique p2p +// listen ports so the two nodes (and any locally running instance) don't +// collide on the default incomingPort, plus a download cap on node2. +writeFileSync(join(dirs.node1, 'settings.json'), JSON.stringify({ network: { incomingPort: P2P_PORT_1 } })); +writeFileSync(join(dirs.node2, 'settings.json'), JSON.stringify({ network: { incomingPort: P2P_PORT_2, maxDownloadSpeed: 256 } })); +// 8 MB payload + 256 KB/s cap on the downloader ⇒ ~32 s transfer window, +// long enough to leave the network mid-flight. +writeFileSync(join(dirs.share, 'payload.bin'), randomBytes(8 * 1024 * 1024)); + +const log1 = join(tmp, 'node1.log'); +const log2 = join(tmp, 'node2.log'); + +function spawnNode(datadir: string, port: number, logPath: string) { + // --host 127.0.0.1 keeps the bind IPv4 — plain `localhost` binds only [::1] + // on Windows and the ws:// client below would never connect. + return Bun.spawn(['bun', 'run', 'backend/src/app.ts', '--datadir', datadir, '--port', String(port), '--host', '127.0.0.1'], { + cwd: REPO_ROOT, + stdout: Bun.file(logPath), + stderr: Bun.file(logPath + '.err'), + stdin: 'ignore', + }); +} + +/** `bun run` wraps the app in a child process — kill the whole tree. */ +function killTree(pid: number): void { + if (process.platform === 'win32') Bun.spawnSync(['taskkill', '/F', '/T', '/PID', String(pid)], { stdout: 'ignore', stderr: 'ignore' }); + else { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already gone + } + } +} + +function tail(path: string, lines = 15): string { + if (!existsSync(path)) return '(no log)'; + const all = readFileSync(path, 'utf8').trim().split('\n'); + return all.slice(-lines).join('\n'); +} + +const proc1 = spawnNode(dirs.node1, WS_PORT_1, log1); +const proc2 = spawnNode(dirs.node2, WS_PORT_2, log2); +console.log(`[setup] node1 ws:${WS_PORT_1} pid:${proc1.pid}, node2 ws:${WS_PORT_2} pid:${proc2.pid}, tmp: ${tmp}`); + +const node1 = new TestClient(`ws://127.0.0.1:${WS_PORT_1}`); +const node2 = new TestClient(`ws://127.0.0.1:${WS_PORT_2}`); + +let failedHard = false; +try { + await node1.waitConnected(30000); + await node2.waitConnected(30000); + check('both nodes started and accept WS', true); + + node2.subscribeAll(); + node2.subscribe('search:lishs:update'); + node2.subscribe('search:lishs:complete'); + await sleep(300); + + // --- join the same lishnet on both nodes ------------------------------- + const netDef = { networkID: NET_ID, name: 'Net leave verify', description: '', bootstrapPeers: [], created: new Date().toISOString(), enabled: true }; + await node1.call('lishnets.add', { network: netDef }); + await node2.call('lishnets.add', { network: netDef }); + await node1.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); + await node2.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); + + const addrs: string[] = await node1.call('lishnets.getAddresses'); + const loop = addrs.find(a => a.includes('127.0.0.1')) ?? addrs[0]; + if (!loop) throw new Error('node1 has no listen addresses'); + await node2.call('lishnets.connect', { multiaddr: loop }); + + const meshUp = await poll('mesh up', 40000, 500, async () => { + const s1 = await node1.call('lishnets.getStatus', { networkID: NET_ID }); + const s2 = await node2.call('lishnets.getStatus', { networkID: NET_ID }); + return (s1.connectedPeers?.length ?? 0) >= 1 && (s2.connectedPeers?.length ?? 0) >= 1 ? true : undefined; + }); + check('nodes joined the lishnet and see each other', meshUp === true); + if (!meshUp) throw new Error('mesh never formed'); + + // --- share content on node1 -------------------------------------------- + const created = await node1.call('lishs.create', { name: LISH_NAME, dataPath: dirs.share, addToSharing: true }); + const lishID: string = created?.id ?? created?.lishID ?? created?.lish?.id; + if (!lishID) throw new Error(`lishs.create gave no id: ${JSON.stringify(created).slice(0, 200)}`); + console.log(`[setup] created LISH ${lishID.slice(0, 12)}…`); + await node1.call('transfer.enableUpload', { lishID }).catch(() => {}); + + // --- positive control: search finds the seeder -------------------------- + // retried a few times: right after subscribe the gossipsub mesh may still + // be grafting, so the first publish can miss the other node + const searchOnce = async (timeoutMs: number): Promise => { + const wait = node2.waitForEvent('search:lishs:update', d => (d.lishs ?? []).some((l: any) => l.id === lishID), timeoutMs).catch(() => undefined); + await node2.call('search.startSearch', { query: LISH_NAME }); + return await wait; + }; + let found: any; + for (let i = 0; i < 3 && found === undefined; i++) found = await searchOnce(8000); + check('search from node2 finds the LISH while node1 is joined', found !== undefined); + + // --- start the download on node2 ---------------------------------------- + const lishFile = join(tmp, 'manifest.lish'); + await node1.call('lishs.exportToFile', { lishID, filePath: lishFile }); + await node2.call('lishs.importFromFile', { filePath: lishFile, downloadPath: dirs.dl2, enableDownloading: true }); + + const downloadPeerCount = async (): Promise => { + const snap = await node2.call('transfer.debugPeers', { lishID }); + return (snap?.entries ?? []).filter((e: any) => e.direction === 'download').length; + }; + + const downloading = await poll('download has a peer', 60000, 1000, async () => ((await downloadPeerCount()) >= 1 ? true : undefined)); + check('node2 download is running with node1 as peer', downloading === true); + if (!downloading) throw new Error('download never started'); + + // ===================================================================== + // ACTION 1 — the SEEDER (node1) leaves the lishnet mid-transfer + // ===================================================================== + await node1.call('lishnets.setEnabled', { networkID: NET_ID, enabled: false }); + console.log('[action] node1 left the lishnet'); + + const starved = await poll('download starved', 30000, 1000, async () => ((await downloadPeerCount()) === 0 ? true : undefined)); + check('in-flight download loses the leaver as peer (transfer stops)', starved === true); + + // The peer must STAY gone: the downloader re-dials stored addresses on its + // ~10s retry cycle, and without the getChunk serve-gate the transfer would + // silently resume over the fresh transport connection. Watch two retry + // cycles with a tight tick to catch even a short-lived re-add. + let cameBack = false; + if (starved === true) { + const watchUntil = Date.now() + 25000; + while (Date.now() < watchUntil) { + if ((await downloadPeerCount()) > 0) { + cameBack = true; + break; + } + await sleep(500); + } + } + check('leaver does not come back as a download peer (retry re-dial is refused)', starved === true && !cameBack); + + // search must no longer find the leaver + const foundAfter = await (async () => { + const wait = node2.waitForEvent('search:lishs:update', d => (d.lishs ?? []).some((l: any) => l.id === lishID), 12000).catch(() => undefined); + await node2.call('search.startSearch', { query: LISH_NAME }); + return await wait; + })(); + check('search from node2 NO LONGER finds the LISH after node1 left', foundAfter === undefined); + + // informative only: transport-level connection state (reconnect from the + // other side via its own keep-alive tag is possible and harmless) + const peers1 = await node1.call('lishnets.getPeers', {}).catch(() => []); + console.log(`[info] node1 connection count after leave: ${Array.isArray(peers1) ? peers1.length : '?'}`); + + // ===================================================================== + // ACTION 2 — the DOWNLOADER (node2) leaves its last joined lishnet + // ===================================================================== + const disabledEvt = node2.waitForEvent('transfer.download:disabled', d => d.lishID === lishID, 20000).catch(() => undefined); + await node2.call('lishnets.setEnabled', { networkID: NET_ID, enabled: false }); + console.log('[action] node2 left the lishnet'); + + const gotDisabled = await disabledEvt; + let disabledViaPoll = false; + if (gotDisabled === undefined) { + // fallback: poll the transfer list for the disabled flag + disabledViaPoll = + (await poll('download disabled', 15000, 1000, async () => { + const list = await node2.call('transfer.getActiveTransfers'); + const item = (Array.isArray(list) ? list : (list?.items ?? [])).find((t: any) => t.lishID === lishID || t.id === lishID); + if (!item) return true; // downloader torn down entirely also counts + return item.downloadDisabled === true || item.disabled === true ? true : undefined; + })) === true; + } + check('download bound to the left lishnet is disabled on the downloader side', gotDisabled !== undefined || disabledViaPoll); +} catch (err: any) { + failedHard = true; + console.error(`\n[verify] aborted: ${err?.message ?? err}`); + console.error('--- node1 log tail ---\n' + tail(log1) + '\n--- node1 err tail ---\n' + tail(log1 + '.err')); + console.error('--- node2 log tail ---\n' + tail(log2) + '\n--- node2 err tail ---\n' + tail(log2 + '.err')); +} finally { + node1.destroy(); + node2.destroy(); + killTree(proc1.pid); + killTree(proc2.pid); + await sleep(500); + if (failedHard || results.some(r => !r.pass)) { + console.log(`[verify] keeping tmp dir for inspection: ${tmp}`); + } else { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + // Windows can hold file locks briefly after kill — leftover tmp is harmless + } + } +} + +console.log('\n================ LEAVE-NETWORK VERIFY SUMMARY ================'); +for (const r of results) console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name}`); +const failed = results.filter(r => !r.pass).length + (failedHard ? 1 : 0); +console.log(failed === 0 ? 'ALL CHECKS PASSED' : `${failed} CHECK(S) FAILED`); +process.exit(failed === 0 ? 0 : 1); From 7d413ebf478f46157dc4db439529690ade3ee6e9 Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 3 Jul 2026 21:36:08 +0200 Subject: [PATCH 15/49] test(dev): add interactive 2-node scenario for manual UI checks --- .../tests/dev/ui-scenario-leave-network.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 backend/tests/dev/ui-scenario-leave-network.ts diff --git a/backend/tests/dev/ui-scenario-leave-network.ts b/backend/tests/dev/ui-scenario-leave-network.ts new file mode 100644 index 00000000..9f5f48cd --- /dev/null +++ b/backend/tests/dev/ui-scenario-leave-network.ts @@ -0,0 +1,79 @@ +/** + * Throwaway helper for manual/Playwright UI verification of the leave-lishnet disconnect behaviour. + * Boots two nodes on FIXED ports, joins a shared lishnet, shares an 8 MB LISH + * from node1 and starts a throttled download on node2 — then keeps running so + * a frontend (pointed at node2) can be inspected. Ctrl+C / kill to stop. + * + * Run from repo root: bun run backend/tests/dev/ui-scenario-leave-network.ts + */ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { TestClient } from '../e2e/helpers/ws-test-client.ts'; + +const REPO_ROOT = resolve(import.meta.dir, '../../..'); +const NET_ID = 'net-leave-ui'; +const WS1 = 44911; +const WS2 = 44912; + +const tmp = mkdtempSync(join(tmpdir(), 'lish-leave-ui-')); +const dirs = { node1: join(tmp, 'node1'), node2: join(tmp, 'node2'), share: join(tmp, 'share'), dl2: join(tmp, 'dl2') }; +for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true }); +writeFileSync(join(dirs.node1, 'settings.json'), JSON.stringify({ network: { incomingPort: WS1 + 1000 } })); +writeFileSync(join(dirs.node2, 'settings.json'), JSON.stringify({ network: { incomingPort: WS2 + 1000, maxDownloadSpeed: 64 } })); +writeFileSync(join(dirs.share, 'payload.bin'), randomBytes(8 * 1024 * 1024)); + +function spawnNode(datadir: string, port: number, logPath: string) { + return Bun.spawn(['bun', 'run', 'backend/src/app.ts', '--datadir', datadir, '--port', String(port), '--host', '127.0.0.1'], { + cwd: REPO_ROOT, + stdout: Bun.file(logPath), + stderr: Bun.file(logPath + '.err'), + stdin: 'ignore', + }); +} + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +const p1 = spawnNode(dirs.node1, WS1, join(tmp, 'node1.log')); +const p2 = spawnNode(dirs.node2, WS2, join(tmp, 'node2.log')); +console.log(`[ui-leave] node1 pid=${p1.pid} ws=${WS1}, node2 pid=${p2.pid} ws=${WS2}, tmp=${tmp}`); + +const node1 = new TestClient(`ws://127.0.0.1:${WS1}`); +const node2 = new TestClient(`ws://127.0.0.1:${WS2}`); +await node1.waitConnected(30000); +await node2.waitConnected(30000); + +const netDef = { networkID: NET_ID, name: 'Net leave UI', description: '', bootstrapPeers: [], created: new Date().toISOString(), enabled: true }; +await node1.call('lishnets.add', { network: netDef }); +await node2.call('lishnets.add', { network: netDef }); +await node1.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); +await node2.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); + +const addrs: string[] = await node1.call('lishnets.getAddresses'); +const loop = addrs.find(a => a.includes('127.0.0.1')) ?? addrs[0]!; +await node2.call('lishnets.connect', { multiaddr: loop }); + +for (let i = 0; i < 60; i++) { + const s2 = await node2.call('lishnets.getStatus', { networkID: NET_ID }).catch(() => undefined); + if ((s2?.connectedPeers?.length ?? 0) >= 1) break; + await sleep(500); +} + +const created = await node1.call('lishs.create', { name: 'lish-leave-ui', dataPath: dirs.share, addToSharing: true }); +const lishID: string = created?.id ?? created?.lishID ?? created?.lish?.id; +await node1.call('transfer.enableUpload', { lishID }).catch(() => {}); + +const lishFile = join(tmp, 'manifest.lish'); +await node1.call('lishs.exportToFile', { lishID, filePath: lishFile }); +await node2.call('lishs.importFromFile', { filePath: lishFile, downloadPath: dirs.dl2, enableDownloading: true }); + +for (let i = 0; i < 60; i++) { + const snap = await node2.call('transfer.debugPeers', { lishID }).catch(() => undefined); + if ((snap?.entries ?? []).filter((e: any) => e.direction === 'download').length >= 1) break; + await sleep(1000); +} + +console.log(`READY lishID=${lishID} ws1=${WS1} ws2=${WS2}`); +// keep the nodes alive for interactive/Playwright inspection +setInterval(() => {}, 60_000); From caa7823e9b8c2e42dd2b269d132f26b88b4f7548 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 8 Jul 2026 16:32:35 +0200 Subject: [PATCH 16/49] fix(network): limit leave disconnect exemption to real infrastructure --- backend/src/protocol/network.ts | 35 ++++++++--- .../infra-peer-classification.test.ts | 59 +++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 backend/tests/unit/protocol/infra-peer-classification.test.ts diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 8c1afaf9..6e9a574a 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -119,6 +119,14 @@ export class Network { */ private readonly seenSearchIDs = new Map(); private bootstrapPeerIDs: Set = new Set(); + /** + * Peer IDs whose bootstrap entries came from explicit network config + * ('configured' origin — startup config or a manual bootstrap edit). Kept + * separate from bootstrapPeerIDs, which also collects peer-announce + * discoveries: those are plain content peers and must remain + * disconnectable by lishnet leave (isBootstrapOrRelayPeer). + */ + private configuredBootstrapPeerIDs: Set = new Set(); private dcutrPeers: Set = new Set(); private bootstrapMultiaddrs: any[] = []; @@ -387,6 +395,8 @@ export class Network { myPeerID: privateKey.publicKey.toString(), }); this.bootstrapPeerIDs = bootstrapPeerIDs; + // Config-time bootstrap entries are by definition 'configured'. + this.configuredBootstrapPeerIDs = new Set(bootstrapPeerIDs); this.bootstrapMultiaddrs = bootstrapMultiaddrs; console.log('Creating libp2p node...'); @@ -950,6 +960,7 @@ export class Network { continue; } const peerID = ma.getComponents().find(c => c.code === 421)?.value ?? null; + if (peerID && origin === 'configured') this.configuredBootstrapPeerIDs.add(peerID); const alreadyKnown = !!peerID && this.bootstrapPeerIDs.has(peerID); if (peerID && !alreadyKnown) { this.bootstrapPeerIDs.add(peerID); @@ -1053,21 +1064,31 @@ export class Network { /** * True if the peer is one we must never voluntarily disconnect because it - * provides infrastructure rather than being a plain content peer: a - * configured/known bootstrap peer, or a peer we are currently reaching over a - * circuit-relay connection (dropping the relay would also kill transit for - * any NAT'd siblings reachable only through it). + * provides infrastructure rather than being a plain content peer: an + * explicitly configured bootstrap peer, or a relay some of our circuit + * connections are routed THROUGH (dropping it would also kill transit for + * any NAT'd peers reachable only via that relay). + * + * Peer-announce-discovered bootstrap entries and peers merely REACHED over + * a relay are plain content peers — hanging those up touches only their own + * connection, so lishnet leave may disconnect them. * * Used by lishnet leave to decide which topic peers are safe to hang up — * leaving an empty lishnet must not tear down shared bootstrap/relay links * that other still-joined lishnets depend on. */ isBootstrapOrRelayPeer(peerID: string): boolean { - if (this.bootstrapPeerIDs.has(peerID)) return true; + if (this.configuredBootstrapPeerIDs.has(peerID)) return true; if (!this.node) return false; try { - const conns = this.node.getConnections(peerIDFromString(peerID)); - return conns.some(c => Circuit.matches(c.remoteAddr)); + // A relay's ID is the hop right before /p2p-circuit in a circuit address: + // /ip4/../tcp/../p2p//p2p-circuit/p2p/ + for (const c of this.node.getConnections()) { + if (!Circuit.matches(c.remoteAddr)) continue; + const relayPrefix = c.remoteAddr.toString().split('/p2p-circuit')[0]!; + if (relayPrefix.endsWith(`/p2p/${peerID}`)) return true; + } + return false; } catch { return false; } diff --git a/backend/tests/unit/protocol/infra-peer-classification.test.ts b/backend/tests/unit/protocol/infra-peer-classification.test.ts new file mode 100644 index 00000000..28261a96 --- /dev/null +++ b/backend/tests/unit/protocol/infra-peer-classification.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll } from 'bun:test'; +import { multiaddr } from '@multiformats/multiaddr'; +import { generateKeyPair } from '@libp2p/crypto/keys'; +import { peerIdFromPrivateKey } from '@libp2p/peer-id'; +import { Network } from '../../../src/protocol/network.ts'; + +/** + * isBootstrapOrRelayPeer decides which peers lishnet leave may hang up. + * Exempt: explicitly configured bootstrap peers and relays our circuit + * connections are routed through. NOT exempt: peer-announce-discovered + * bootstrap entries and NAT'd peers merely reached via a relay. + */ + +let configuredID: string; +let discoveredID: string; +let relayID: string; +let natID: string; + +async function newPeerID(): Promise { + return peerIdFromPrivateKey(await generateKeyPair('Ed25519')).toString(); +} + +/** Bare Network with only the state isBootstrapOrRelayPeer reads. */ +function bareNetwork(conns: Array<{ remoteAddr: ReturnType }>): Network { + const net = Object.create(Network.prototype) as any; + net.configuredBootstrapPeerIDs = new Set([configuredID]); + net.bootstrapPeerIDs = new Set([configuredID, discoveredID]); + net.node = { getConnections: () => conns }; + return net as Network; +} + +describe('Network.isBootstrapOrRelayPeer — leave disconnect exemptions', () => { + beforeAll(async () => { + [configuredID, discoveredID, relayID, natID] = await Promise.all([newPeerID(), newPeerID(), newPeerID(), newPeerID()]); + }); + + it('exempts an explicitly configured bootstrap peer', () => { + expect(bareNetwork([]).isBootstrapOrRelayPeer(configuredID)).toBe(true); + }); + + it('does NOT exempt a peer-announce-discovered bootstrap entry', () => { + expect(bareNetwork([]).isBootstrapOrRelayPeer(discoveredID)).toBe(false); + }); + + it('exempts the relay a circuit connection is routed through', () => { + const circuit = { remoteAddr: multiaddr(`/ip4/198.51.100.7/tcp/4001/p2p/${relayID}/p2p-circuit/p2p/${natID}`) }; + expect(bareNetwork([circuit]).isBootstrapOrRelayPeer(relayID)).toBe(true); + }); + + it('does NOT exempt a NAT peer merely reached via a relay', () => { + const circuit = { remoteAddr: multiaddr(`/ip4/198.51.100.7/tcp/4001/p2p/${relayID}/p2p-circuit/p2p/${natID}`) }; + expect(bareNetwork([circuit]).isBootstrapOrRelayPeer(natID)).toBe(false); + }); + + it('does NOT exempt a plain directly-connected content peer', () => { + const direct = { remoteAddr: multiaddr(`/ip4/198.51.100.8/tcp/4001/p2p/${natID}`) }; + expect(bareNetwork([direct]).isBootstrapOrRelayPeer(natID)).toBe(false); + }); +}); From 96c81a4ddfa0e9d10255f922fe97a5c79e50e6a4 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 8 Jul 2026 16:33:18 +0200 Subject: [PATCH 17/49] fix(network): keep peer-disconnect subscriptions across node restarts --- backend/src/protocol/network.ts | 54 +++++++++++++++------------------ 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 6e9a574a..e53171ba 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -181,6 +181,12 @@ export class Network { /** Per-instance dedup set for PX ingress log keys; owned here, passed into gossipsub-patches deps. */ private readonly pxIngressLogKeys = new Set(); + /** + * Handlers subscribed via {@link onPeerDisconnect}. Held at Network level + * (not bound to a node instance) so subscriptions survive node restarts. + */ + private readonly peerDisconnectHandlers = new Set<(peerID: string) => void>(); + /** Handles incoming LISH-serving pubsub messages (want, searchLishs). */ private readonly lishHandlers: LISHServingHandlers; @@ -275,38 +281,20 @@ export class Network { } /** - * Subscribe to libp2p `peer:disconnect` events for the duration of the - * returned disposer. The handler receives the disconnected peer's ID as a - * string. + * Subscribe to peer disconnects for the duration of the returned disposer. + * The handler receives the disconnected peer's ID as a string. * - * Registered via {@link addListener} (memory hygiene — never call - * `addEventListener` directly) so a forgotten disposer is still cleaned up by - * {@link stop}. The returned disposer removes the listener from the tracked - * list so short-lived subscribers (e.g. a Downloader) do not leak across - * their own lifecycle. Used by downloads to drop a vanished peer from their - * per-LISH peer manager immediately, instead of waiting for the next failed - * dial/probe to notice the dead connection. + * Handlers live at Network level, NOT on the current libp2p node: the + * permanent `peer:disconnect` listener installed by {@link start} fans out + * to this set, so subscriptions survive a node restart (identity + * import/regenerate) that would otherwise silently drop them together with + * the old node's listeners. Used by downloads to drop a vanished peer from + * their per-LISH peer manager immediately, instead of waiting for the next + * failed dial/probe to notice the dead connection. */ onPeerDisconnect(handler: (peerID: string) => void): () => void { - if (!this.node) return () => {}; - const node = this.node; - const listener = (evt: any): void => { - const pid = evt.detail?.toString?.(); - if (pid) handler(pid); - }; - this.addListener(node, 'peer:disconnect', listener); - let disposed = false; - return () => { - if (disposed) return; - disposed = true; - try { - node.removeEventListener('peer:disconnect', listener as any); - } catch { - // Node may already be stopped — stop() walked the tracked list already. - } - const idx = this.listeners.findIndex(l => l.target === node && l.event === 'peer:disconnect' && l.handler === listener); - if (idx >= 0) this.listeners.splice(idx, 1); - }; + this.peerDisconnectHandlers.add(handler); + return () => this.peerDisconnectHandlers.delete(handler); } /** @@ -604,6 +592,14 @@ export class Network { if (topic.startsWith(LISH_TOPIC_PREFIX)) this.lastMeshChange.set(topic, now); } } + // Fan out to Network-level subscribers (see onPeerDisconnect). + for (const h of this.peerDisconnectHandlers) { + try { + h(peerID); + } catch (err: any) { + trace(`[NET] peer-disconnect subscriber error: ${err?.message ?? err}`); + } + } this.schedulePeerCountCheck(); }); From c1fb566f6d53fd6da13212eeb9d1596217d411b5 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 8 Jul 2026 16:33:18 +0200 Subject: [PATCH 18/49] test(dev): poll downloadEnabled list in leave verify fallback --- backend/tests/dev/verify-leave-network.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/tests/dev/verify-leave-network.ts b/backend/tests/dev/verify-leave-network.ts index 793ca246..a8739873 100644 --- a/backend/tests/dev/verify-leave-network.ts +++ b/backend/tests/dev/verify-leave-network.ts @@ -220,13 +220,12 @@ try { const gotDisabled = await disabledEvt; let disabledViaPoll = false; if (gotDisabled === undefined) { - // fallback: poll the transfer list for the disabled flag + // fallback: poll lishs.list until the LISH drops out of the downloadEnabled set disabledViaPoll = (await poll('download disabled', 15000, 1000, async () => { - const list = await node2.call('transfer.getActiveTransfers'); - const item = (Array.isArray(list) ? list : (list?.items ?? [])).find((t: any) => t.lishID === lishID || t.id === lishID); - if (!item) return true; // downloader torn down entirely also counts - return item.downloadDisabled === true || item.disabled === true ? true : undefined; + const list = await node2.call('lishs.list'); + const enabled: string[] = list?.downloadEnabled ?? []; + return enabled.includes(lishID) ? undefined : true; })) === true; } check('download bound to the left lishnet is disabled on the downloader side', gotDisabled !== undefined || disabledViaPoll); From 7df3ba50dd96c71ba26d173bd0230c26c0548d26 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 21 Jul 2026 22:27:33 +0200 Subject: [PATCH 19/49] fix(network): clear native keep-alive tag on peer disconnect --- backend/src/protocol/network.ts | 13 ++-- .../unit/protocol/network-disconnect.test.ts | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 backend/tests/unit/protocol/network-disconnect.test.ts diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index e53171ba..404840ab 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -1135,12 +1135,17 @@ export class Network { trace(`[NET] disconnectPeer: invalid peerID ${peerID.slice(0, 16)}: ${err?.message ?? err}`); return; } - // Remove the fleet keep-alive tag FIRST so the imminent hangUp does not - // race the ReconnectQueue back into a re-dial. Passing undefined as the - // tag value removes it (per @libp2p/interface PeerStore merge semantics). + // Remove the keep-alive tags FIRST so the imminent hangUp does not race + // the ReconnectQueue back into a re-dial. Both tags matter: the custom + // 'keep-alive-fleet' tag (peer-announce intake) and the native KEEP_ALIVE + // tag (stamped by addBootstrapPeers on every successfully dialed entry, + // including discovered ones) — libp2p itself re-dials any peer carrying a + // keep-alive tag, which would silently undo this disconnect. Passing + // undefined as the tag value removes it (per @libp2p/interface PeerStore + // merge semantics). try { await this.node.peerStore.merge(pid, { - tags: { 'keep-alive-fleet': undefined }, + tags: { 'keep-alive-fleet': undefined, [KEEP_ALIVE]: undefined }, }); } catch (err: any) { trace(`[NET] disconnectPeer: tag removal failed for ${peerID.slice(0, 16)}: ${err?.message ?? err}`); diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts new file mode 100644 index 00000000..a636b535 --- /dev/null +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'bun:test'; +import { KEEP_ALIVE } from '@libp2p/interface'; +import { Network } from '../../../src/protocol/network.ts'; + +/** + * Unit tests for Network.disconnectPeer tag hygiene: hanging up a peer must + * remove BOTH keep-alive tags — the custom 'keep-alive-fleet' tag and the + * native libp2p KEEP_ALIVE tag. Leaving either behind makes libp2p re-dial + * the peer right after the hangUp, silently undoing the disconnect. + */ + +const PEER_ID = '12D3KooWPvH1oQjQZS8TtucG4NsW2PsnW87jwMAiRLKgrNGS17fo'; + +function makeNetwork() { + const merges: Array<{ tags: Record }> = []; + const hungUp: string[] = []; + const network = Object.create(Network.prototype) as Network; + (network as any).node = { + peerStore: { + async merge(_pid: unknown, patch: { tags: Record }): Promise { + merges.push(patch); + }, + }, + async hangUp(pid: { toString(): string }): Promise { + hungUp.push(pid.toString()); + }, + }; + return { network, merges, hungUp }; +} + +describe('Network.disconnectPeer — keep-alive tag removal', () => { + it('clears both keep-alive-fleet and native KEEP_ALIVE tags before hanging up', async () => { + const { network, merges, hungUp } = makeNetwork(); + await network.disconnectPeer(PEER_ID); + expect(merges.length).toBe(1); + const tags = merges[0]!.tags; + expect(Object.keys(tags)).toContain('keep-alive-fleet'); + expect(Object.keys(tags)).toContain(KEEP_ALIVE); + expect(tags['keep-alive-fleet']).toBeUndefined(); + expect(tags[KEEP_ALIVE]).toBeUndefined(); + expect(hungUp).toEqual([PEER_ID]); + }); + + it('still hangs up when tag removal fails', async () => { + const { network, hungUp } = makeNetwork(); + (network as any).node.peerStore.merge = async (): Promise => { + throw new Error('merge failed'); + }; + await network.disconnectPeer(PEER_ID); + expect(hungUp).toEqual([PEER_ID]); + }); + + it('is a no-op for an invalid peer id', async () => { + const { network, merges, hungUp } = makeNetwork(); + await network.disconnectPeer('not-a-peer-id'); + expect(merges).toEqual([]); + expect(hungUp).toEqual([]); + }); +}); From b5f61c57d8c719408deae794ca144ebe6112fa5d Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 21 Jul 2026 22:27:33 +0200 Subject: [PATCH 20/49] fix(transfer): drop runtime download-enabled flag on last-lishnet leave --- backend/src/api/transfer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 600da92e..53b4ee62 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -127,8 +127,14 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, if (ids.some(id => networks.isJoined(id))) continue; console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); dl.disable(); + // Drop the runtime enabled flag (no DB persist) so `lishs.list` reports + // the download as stopped and restartDownloadIfEnabled cannot silently + // revive it after verification while no usable lishnet is joined. The + // DB flag stays untouched, so an app restart with the lishnet re-joined + // resumes the download. + downloadEnabledLishs.delete(lishID); // dl.disable() alone emits nothing over WS — tell the FE the download - // stopped. DB enabled flags stay untouched so a re-join can resume it. + // stopped. broadcast?.('transfer.download:disabled', { lishID }); } }; From 9992b237ea98922c14ab95795b3565a5e0fcbd90 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 21 Jul 2026 22:31:47 +0200 Subject: [PATCH 21/49] chore(test): drop dev verification scripts from the branch --- .../tests/dev/ui-scenario-leave-network.ts | 79 ------ backend/tests/dev/verify-leave-network.ts | 258 ------------------ 2 files changed, 337 deletions(-) delete mode 100644 backend/tests/dev/ui-scenario-leave-network.ts delete mode 100644 backend/tests/dev/verify-leave-network.ts diff --git a/backend/tests/dev/ui-scenario-leave-network.ts b/backend/tests/dev/ui-scenario-leave-network.ts deleted file mode 100644 index 9f5f48cd..00000000 --- a/backend/tests/dev/ui-scenario-leave-network.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Throwaway helper for manual/Playwright UI verification of the leave-lishnet disconnect behaviour. - * Boots two nodes on FIXED ports, joins a shared lishnet, shares an 8 MB LISH - * from node1 and starts a throttled download on node2 — then keeps running so - * a frontend (pointed at node2) can be inspected. Ctrl+C / kill to stop. - * - * Run from repo root: bun run backend/tests/dev/ui-scenario-leave-network.ts - */ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { randomBytes } from 'node:crypto'; -import { TestClient } from '../e2e/helpers/ws-test-client.ts'; - -const REPO_ROOT = resolve(import.meta.dir, '../../..'); -const NET_ID = 'net-leave-ui'; -const WS1 = 44911; -const WS2 = 44912; - -const tmp = mkdtempSync(join(tmpdir(), 'lish-leave-ui-')); -const dirs = { node1: join(tmp, 'node1'), node2: join(tmp, 'node2'), share: join(tmp, 'share'), dl2: join(tmp, 'dl2') }; -for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true }); -writeFileSync(join(dirs.node1, 'settings.json'), JSON.stringify({ network: { incomingPort: WS1 + 1000 } })); -writeFileSync(join(dirs.node2, 'settings.json'), JSON.stringify({ network: { incomingPort: WS2 + 1000, maxDownloadSpeed: 64 } })); -writeFileSync(join(dirs.share, 'payload.bin'), randomBytes(8 * 1024 * 1024)); - -function spawnNode(datadir: string, port: number, logPath: string) { - return Bun.spawn(['bun', 'run', 'backend/src/app.ts', '--datadir', datadir, '--port', String(port), '--host', '127.0.0.1'], { - cwd: REPO_ROOT, - stdout: Bun.file(logPath), - stderr: Bun.file(logPath + '.err'), - stdin: 'ignore', - }); -} - -const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); - -const p1 = spawnNode(dirs.node1, WS1, join(tmp, 'node1.log')); -const p2 = spawnNode(dirs.node2, WS2, join(tmp, 'node2.log')); -console.log(`[ui-leave] node1 pid=${p1.pid} ws=${WS1}, node2 pid=${p2.pid} ws=${WS2}, tmp=${tmp}`); - -const node1 = new TestClient(`ws://127.0.0.1:${WS1}`); -const node2 = new TestClient(`ws://127.0.0.1:${WS2}`); -await node1.waitConnected(30000); -await node2.waitConnected(30000); - -const netDef = { networkID: NET_ID, name: 'Net leave UI', description: '', bootstrapPeers: [], created: new Date().toISOString(), enabled: true }; -await node1.call('lishnets.add', { network: netDef }); -await node2.call('lishnets.add', { network: netDef }); -await node1.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); -await node2.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); - -const addrs: string[] = await node1.call('lishnets.getAddresses'); -const loop = addrs.find(a => a.includes('127.0.0.1')) ?? addrs[0]!; -await node2.call('lishnets.connect', { multiaddr: loop }); - -for (let i = 0; i < 60; i++) { - const s2 = await node2.call('lishnets.getStatus', { networkID: NET_ID }).catch(() => undefined); - if ((s2?.connectedPeers?.length ?? 0) >= 1) break; - await sleep(500); -} - -const created = await node1.call('lishs.create', { name: 'lish-leave-ui', dataPath: dirs.share, addToSharing: true }); -const lishID: string = created?.id ?? created?.lishID ?? created?.lish?.id; -await node1.call('transfer.enableUpload', { lishID }).catch(() => {}); - -const lishFile = join(tmp, 'manifest.lish'); -await node1.call('lishs.exportToFile', { lishID, filePath: lishFile }); -await node2.call('lishs.importFromFile', { filePath: lishFile, downloadPath: dirs.dl2, enableDownloading: true }); - -for (let i = 0; i < 60; i++) { - const snap = await node2.call('transfer.debugPeers', { lishID }).catch(() => undefined); - if ((snap?.entries ?? []).filter((e: any) => e.direction === 'download').length >= 1) break; - await sleep(1000); -} - -console.log(`READY lishID=${lishID} ws1=${WS1} ws2=${WS2}`); -// keep the nodes alive for interactive/Playwright inspection -setInterval(() => {}, 60_000); diff --git a/backend/tests/dev/verify-leave-network.ts b/backend/tests/dev/verify-leave-network.ts deleted file mode 100644 index a8739873..00000000 --- a/backend/tests/dev/verify-leave-network.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Live 2-node verification of the leave-lishnet disconnect behaviour. - * - * Scenario from the bug report: a node that shares content leaves the - * network, yet other nodes still find it via search and an in-flight - * download keeps running. After the fix, leaving must: - * 1. stop the leaver from answering searches of that lishnet, - * 2. starve any in-flight download sourced from the leaver (peer dropped), - * 3. disable a download whose LAST joined lishnet was left (downloader side). - * - * Run from the repo root: bun run backend/tests/dev/verify-leave-network.ts - * Exit code 0 = all checks passed. - */ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { randomBytes } from 'node:crypto'; -import { TestClient } from '../e2e/helpers/ws-test-client.ts'; - -const REPO_ROOT = resolve(import.meta.dir, '../../..'); -const NET_ID = 'net-leave-verify'; -const LISH_NAME = 'lish-leave-verify'; -const WS_PORT_1 = 44700 + Math.floor(Math.random() * 100); -const WS_PORT_2 = WS_PORT_1 + 1; - -const results: Array<{ name: string; pass: boolean; note?: string }> = []; -function check(name: string, pass: boolean, note?: string): void { - results.push(note === undefined ? { name, pass } : { name, pass, note }); - console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${note ? ` — ${note}` : ''}`); -} - -function sleep(ms: number): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -async function poll(label: string, timeoutMs: number, intervalMs: number, fn: () => Promise): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const v = await fn(); - if (v !== undefined) return v; - } catch { - // endpoint may not be ready yet — keep polling - } - await sleep(intervalMs); - } - console.log(`[poll] ${label}: timed out after ${timeoutMs}ms`); - return undefined; -} - -const P2P_PORT_1 = WS_PORT_1 + 1000; -const P2P_PORT_2 = WS_PORT_2 + 1000; - -const tmp = mkdtempSync(join(tmpdir(), 'lish-leave-verify-')); -const dirs = { - node1: join(tmp, 'node1'), - node2: join(tmp, 'node2'), - share: join(tmp, 'share'), - dl2: join(tmp, 'dl2'), -}; -for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true }); -// Pre-seed partial settings (deep-merged over defaults on load): unique p2p -// listen ports so the two nodes (and any locally running instance) don't -// collide on the default incomingPort, plus a download cap on node2. -writeFileSync(join(dirs.node1, 'settings.json'), JSON.stringify({ network: { incomingPort: P2P_PORT_1 } })); -writeFileSync(join(dirs.node2, 'settings.json'), JSON.stringify({ network: { incomingPort: P2P_PORT_2, maxDownloadSpeed: 256 } })); -// 8 MB payload + 256 KB/s cap on the downloader ⇒ ~32 s transfer window, -// long enough to leave the network mid-flight. -writeFileSync(join(dirs.share, 'payload.bin'), randomBytes(8 * 1024 * 1024)); - -const log1 = join(tmp, 'node1.log'); -const log2 = join(tmp, 'node2.log'); - -function spawnNode(datadir: string, port: number, logPath: string) { - // --host 127.0.0.1 keeps the bind IPv4 — plain `localhost` binds only [::1] - // on Windows and the ws:// client below would never connect. - return Bun.spawn(['bun', 'run', 'backend/src/app.ts', '--datadir', datadir, '--port', String(port), '--host', '127.0.0.1'], { - cwd: REPO_ROOT, - stdout: Bun.file(logPath), - stderr: Bun.file(logPath + '.err'), - stdin: 'ignore', - }); -} - -/** `bun run` wraps the app in a child process — kill the whole tree. */ -function killTree(pid: number): void { - if (process.platform === 'win32') Bun.spawnSync(['taskkill', '/F', '/T', '/PID', String(pid)], { stdout: 'ignore', stderr: 'ignore' }); - else { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // already gone - } - } -} - -function tail(path: string, lines = 15): string { - if (!existsSync(path)) return '(no log)'; - const all = readFileSync(path, 'utf8').trim().split('\n'); - return all.slice(-lines).join('\n'); -} - -const proc1 = spawnNode(dirs.node1, WS_PORT_1, log1); -const proc2 = spawnNode(dirs.node2, WS_PORT_2, log2); -console.log(`[setup] node1 ws:${WS_PORT_1} pid:${proc1.pid}, node2 ws:${WS_PORT_2} pid:${proc2.pid}, tmp: ${tmp}`); - -const node1 = new TestClient(`ws://127.0.0.1:${WS_PORT_1}`); -const node2 = new TestClient(`ws://127.0.0.1:${WS_PORT_2}`); - -let failedHard = false; -try { - await node1.waitConnected(30000); - await node2.waitConnected(30000); - check('both nodes started and accept WS', true); - - node2.subscribeAll(); - node2.subscribe('search:lishs:update'); - node2.subscribe('search:lishs:complete'); - await sleep(300); - - // --- join the same lishnet on both nodes ------------------------------- - const netDef = { networkID: NET_ID, name: 'Net leave verify', description: '', bootstrapPeers: [], created: new Date().toISOString(), enabled: true }; - await node1.call('lishnets.add', { network: netDef }); - await node2.call('lishnets.add', { network: netDef }); - await node1.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); - await node2.call('lishnets.setEnabled', { networkID: NET_ID, enabled: true }); - - const addrs: string[] = await node1.call('lishnets.getAddresses'); - const loop = addrs.find(a => a.includes('127.0.0.1')) ?? addrs[0]; - if (!loop) throw new Error('node1 has no listen addresses'); - await node2.call('lishnets.connect', { multiaddr: loop }); - - const meshUp = await poll('mesh up', 40000, 500, async () => { - const s1 = await node1.call('lishnets.getStatus', { networkID: NET_ID }); - const s2 = await node2.call('lishnets.getStatus', { networkID: NET_ID }); - return (s1.connectedPeers?.length ?? 0) >= 1 && (s2.connectedPeers?.length ?? 0) >= 1 ? true : undefined; - }); - check('nodes joined the lishnet and see each other', meshUp === true); - if (!meshUp) throw new Error('mesh never formed'); - - // --- share content on node1 -------------------------------------------- - const created = await node1.call('lishs.create', { name: LISH_NAME, dataPath: dirs.share, addToSharing: true }); - const lishID: string = created?.id ?? created?.lishID ?? created?.lish?.id; - if (!lishID) throw new Error(`lishs.create gave no id: ${JSON.stringify(created).slice(0, 200)}`); - console.log(`[setup] created LISH ${lishID.slice(0, 12)}…`); - await node1.call('transfer.enableUpload', { lishID }).catch(() => {}); - - // --- positive control: search finds the seeder -------------------------- - // retried a few times: right after subscribe the gossipsub mesh may still - // be grafting, so the first publish can miss the other node - const searchOnce = async (timeoutMs: number): Promise => { - const wait = node2.waitForEvent('search:lishs:update', d => (d.lishs ?? []).some((l: any) => l.id === lishID), timeoutMs).catch(() => undefined); - await node2.call('search.startSearch', { query: LISH_NAME }); - return await wait; - }; - let found: any; - for (let i = 0; i < 3 && found === undefined; i++) found = await searchOnce(8000); - check('search from node2 finds the LISH while node1 is joined', found !== undefined); - - // --- start the download on node2 ---------------------------------------- - const lishFile = join(tmp, 'manifest.lish'); - await node1.call('lishs.exportToFile', { lishID, filePath: lishFile }); - await node2.call('lishs.importFromFile', { filePath: lishFile, downloadPath: dirs.dl2, enableDownloading: true }); - - const downloadPeerCount = async (): Promise => { - const snap = await node2.call('transfer.debugPeers', { lishID }); - return (snap?.entries ?? []).filter((e: any) => e.direction === 'download').length; - }; - - const downloading = await poll('download has a peer', 60000, 1000, async () => ((await downloadPeerCount()) >= 1 ? true : undefined)); - check('node2 download is running with node1 as peer', downloading === true); - if (!downloading) throw new Error('download never started'); - - // ===================================================================== - // ACTION 1 — the SEEDER (node1) leaves the lishnet mid-transfer - // ===================================================================== - await node1.call('lishnets.setEnabled', { networkID: NET_ID, enabled: false }); - console.log('[action] node1 left the lishnet'); - - const starved = await poll('download starved', 30000, 1000, async () => ((await downloadPeerCount()) === 0 ? true : undefined)); - check('in-flight download loses the leaver as peer (transfer stops)', starved === true); - - // The peer must STAY gone: the downloader re-dials stored addresses on its - // ~10s retry cycle, and without the getChunk serve-gate the transfer would - // silently resume over the fresh transport connection. Watch two retry - // cycles with a tight tick to catch even a short-lived re-add. - let cameBack = false; - if (starved === true) { - const watchUntil = Date.now() + 25000; - while (Date.now() < watchUntil) { - if ((await downloadPeerCount()) > 0) { - cameBack = true; - break; - } - await sleep(500); - } - } - check('leaver does not come back as a download peer (retry re-dial is refused)', starved === true && !cameBack); - - // search must no longer find the leaver - const foundAfter = await (async () => { - const wait = node2.waitForEvent('search:lishs:update', d => (d.lishs ?? []).some((l: any) => l.id === lishID), 12000).catch(() => undefined); - await node2.call('search.startSearch', { query: LISH_NAME }); - return await wait; - })(); - check('search from node2 NO LONGER finds the LISH after node1 left', foundAfter === undefined); - - // informative only: transport-level connection state (reconnect from the - // other side via its own keep-alive tag is possible and harmless) - const peers1 = await node1.call('lishnets.getPeers', {}).catch(() => []); - console.log(`[info] node1 connection count after leave: ${Array.isArray(peers1) ? peers1.length : '?'}`); - - // ===================================================================== - // ACTION 2 — the DOWNLOADER (node2) leaves its last joined lishnet - // ===================================================================== - const disabledEvt = node2.waitForEvent('transfer.download:disabled', d => d.lishID === lishID, 20000).catch(() => undefined); - await node2.call('lishnets.setEnabled', { networkID: NET_ID, enabled: false }); - console.log('[action] node2 left the lishnet'); - - const gotDisabled = await disabledEvt; - let disabledViaPoll = false; - if (gotDisabled === undefined) { - // fallback: poll lishs.list until the LISH drops out of the downloadEnabled set - disabledViaPoll = - (await poll('download disabled', 15000, 1000, async () => { - const list = await node2.call('lishs.list'); - const enabled: string[] = list?.downloadEnabled ?? []; - return enabled.includes(lishID) ? undefined : true; - })) === true; - } - check('download bound to the left lishnet is disabled on the downloader side', gotDisabled !== undefined || disabledViaPoll); -} catch (err: any) { - failedHard = true; - console.error(`\n[verify] aborted: ${err?.message ?? err}`); - console.error('--- node1 log tail ---\n' + tail(log1) + '\n--- node1 err tail ---\n' + tail(log1 + '.err')); - console.error('--- node2 log tail ---\n' + tail(log2) + '\n--- node2 err tail ---\n' + tail(log2 + '.err')); -} finally { - node1.destroy(); - node2.destroy(); - killTree(proc1.pid); - killTree(proc2.pid); - await sleep(500); - if (failedHard || results.some(r => !r.pass)) { - console.log(`[verify] keeping tmp dir for inspection: ${tmp}`); - } else { - try { - rmSync(tmp, { recursive: true, force: true }); - } catch { - // Windows can hold file locks briefly after kill — leftover tmp is harmless - } - } -} - -console.log('\n================ LEAVE-NETWORK VERIFY SUMMARY ================'); -for (const r of results) console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name}`); -const failed = results.filter(r => !r.pass).length + (failedHard ? 1 : 0); -console.log(failed === 0 ? 'ALL CHECKS PASSED' : `${failed} CHECK(S) FAILED`); -process.exit(failed === 0 ? 0 : 1); From 6c5027b3e02251e6c3a5e6cbe7e8193322e5f264 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 21 Jul 2026 22:49:26 +0200 Subject: [PATCH 22/49] fix(transfer): stop sourcing a download from a left lishnet and cancel its recovery --- backend/src/api/transfer.ts | 14 +++++++- backend/src/protocol/downloader.ts | 14 +++++++- .../downloader-remove-network.test.ts | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 backend/tests/unit/protocol/downloader-remove-network.test.ts diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 53b4ee62..7ce9f214 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -124,7 +124,14 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, for (const [lishID, dl] of activeDownloaders) { const ids = dl.getNetworkIDs?.() ?? []; if (!ids.includes(networkID)) continue; - if (ids.some(id => networks.isJoined(id))) continue; + if (ids.some(id => networks.isJoined(id))) { + // Another joined lishnet can still source this download — keep it + // running but stop using the network we just left, otherwise the + // downloader keeps broadcasting WANTs and probing peers on a topic + // we are no longer part of. + dl.removeNetwork?.(networkID); + continue; + } console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); dl.disable(); // Drop the runtime enabled flag (no DB persist) so `lishs.list` reports @@ -133,6 +140,11 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // DB flag stays untouched, so an app restart with the lishnet re-joined // resumes the download. downloadEnabledLishs.delete(lishID); + // Cancel any pending error-recovery timer for this LISH — otherwise + // ErrorRecovery, holding the captured downloadWasEnabled=true, could + // re-enable the download once the IO condition clears even though the + // user just stopped it by leaving the network. + recovery.stop(lishID); // dl.disable() alone emits nothing over WS — tell the FE the download // stopped. broadcast?.('transfer.download:disabled', { lishID }); diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index ffb90d4f..8821b309 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -57,7 +57,7 @@ export class Downloader { private readonly dataServer: DataServer; private network: Network; private readonly downloadDir: string; - private readonly networkIDs: string[]; + private networkIDs: string[]; private lishID!: LISHid; private state: State = 'added'; private workMutex = new Mutex(); @@ -119,6 +119,18 @@ export class Downloader { return [...this.networkIDs]; } + /** + * Stop sourcing this download from a lishnet the node just left. Removes the + * network from the set so subsequent WANT broadcasts and topic-peer probes no + * longer reach the left lishnet; peers exclusive to it are hung up separately + * by the leave path. No-op if it was not one of this download's networks or if + * it is the only one left (the caller disables the whole download in that case). + */ + removeNetwork(networkID: string): void { + if (this.networkIDs.length <= 1 || !this.networkIDs.includes(networkID)) return; + this.networkIDs = this.networkIDs.filter(id => id !== networkID); + } + /** * Central state mutation. Validates the requested transition against ALLOWED_TRANSITIONS * and logs a warning (and bails) if the transition is not allowed. diff --git a/backend/tests/unit/protocol/downloader-remove-network.test.ts b/backend/tests/unit/protocol/downloader-remove-network.test.ts new file mode 100644 index 00000000..18eeec05 --- /dev/null +++ b/backend/tests/unit/protocol/downloader-remove-network.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'bun:test'; +import { Downloader } from '../../../src/protocol/downloader.ts'; + +/** + * Unit tests for Downloader.removeNetwork: leaving one lishnet of a multi-network + * download must drop that network from the set (so WANT broadcasts / topic probes + * stop reaching it), while never emptying the set — the caller disables the whole + * download when the last network is left. + */ + +function makeDownloader(networkIDs: string[]): Downloader { + const dl = Object.create(Downloader.prototype) as Downloader; + (dl as any).networkIDs = [...networkIDs]; + return dl; +} + +describe('Downloader.removeNetwork', () => { + it('removes one network from a multi-network download', () => { + const dl = makeDownloader(['net-a', 'net-b']); + dl.removeNetwork('net-a'); + expect(dl.getNetworkIDs()).toEqual(['net-b']); + }); + + it('is a no-op when the network is the only one left', () => { + const dl = makeDownloader(['net-b']); + dl.removeNetwork('net-b'); + expect(dl.getNetworkIDs()).toEqual(['net-b']); + }); + + it('is a no-op for a network the download is not bound to', () => { + const dl = makeDownloader(['net-a', 'net-b']); + dl.removeNetwork('net-c'); + expect(dl.getNetworkIDs()).toEqual(['net-a', 'net-b']); + }); +}); From deb297102501029108226f99dde5880ff120bf3e Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 21 Jul 2026 22:49:26 +0200 Subject: [PATCH 23/49] fix(network): prune bootstrap exemption for removed or left-lishnet peers --- backend/src/lishnet/lishnets.ts | 42 +++++++++++++++++++ backend/src/protocol/network.ts | 11 +++++ .../tests/unit/lishnet/leave-network.test.ts | 24 +++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index facfaf67..a5264584 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -143,6 +143,31 @@ export class Networks { /** * Leave a lishnet (unsubscribe from its topic). */ + /** Peer IDs (the /p2p/ component) of a list of bootstrap multiaddr strings. */ + private static bootstrapPeerIDsOf(bootstrapPeers: string[]): string[] { + const ids: string[] = []; + for (const addr of bootstrapPeers) { + const m = addr.match(/\/p2p\/([^/]+)/); + if (m) ids.push(m[1]!); + } + return ids; + } + + /** Configured-bootstrap peer IDs of a single network. */ + private configuredBootstrapPeerIDsOf(networkID: string): Set { + return new Set(Networks.bootstrapPeerIDsOf(this.get(networkID)?.bootstrapPeers ?? [])); + } + + /** Configured-bootstrap peer IDs of every joined network except `exceptID`. */ + private configuredBootstrapPeerIDsElsewhere(exceptID: string): Set { + const out = new Set(); + for (const nid of this.joinedNetworks) { + if (nid === exceptID) continue; + for (const pid of Networks.bootstrapPeerIDsOf(this.get(nid)?.bootstrapPeers ?? [])) out.add(pid); + } + return out; + } + private async leaveNetwork(id: string): Promise { if (!this.joinedNetworks.has(id)) return; @@ -153,6 +178,14 @@ export class Networks { this.network.unsubscribeTopic(id); this.joinedNetworks.delete(id); + // Drop the bootstrap-exemption for peers configured only for the lishnet we + // just left. Without this a stale exemption would make the disconnect loop + // below skip a peer that is no longer shared with any joined network. + const stillConfigured = this.configuredBootstrapPeerIDsElsewhere(id); + for (const pid of this.configuredBootstrapPeerIDsOf(id)) { + if (!stillConfigured.has(pid)) this.network.pruneConfiguredBootstrapPeer(pid); + } + // Disconnect peers that belonged exclusively to the lishnet we just left. // A peer is kept connected if it is still a subscriber of any OTHER joined // lishnet, or if it is a bootstrap/relay peer (shared infrastructure other @@ -346,6 +379,15 @@ export class Networks { 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 nextIDs = new Set(Networks.bootstrapPeerIDsOf(cleaned)); + const elsewhere = this.configuredBootstrapPeerIDsElsewhere(id); + for (const pid of Networks.bootstrapPeerIDsOf(existing.bootstrapPeers)) { + 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); diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 404840ab..66038a16 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -1073,6 +1073,17 @@ export class Network { * leaving an empty lishnet must not tear down shared bootstrap/relay links * that other still-joined lishnets depend on. */ + /** + * Drop a peer from the configured-bootstrap exemption set. Called by the + * lishnet layer when a bootstrap entry is removed from config or belongs only + * to a lishnet being left, so `isBootstrapOrRelayPeer` stops treating a peer + * that is no longer configured (nor shared with another joined network) as + * infrastructure that leave-network must keep connected. + */ + pruneConfiguredBootstrapPeer(peerID: string): void { + this.configuredBootstrapPeerIDs.delete(peerID); + } + isBootstrapOrRelayPeer(peerID: string): boolean { if (this.configuredBootstrapPeerIDs.has(peerID)) return true; if (!this.node) return false; diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index 915f3c96..37143e1f 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -14,10 +14,12 @@ interface MockNet { unsubscribed: string[]; disconnected: string[]; bootstrapOrRelay: Set; + prunedBootstrap: string[]; getTopicPeers(id: string): string[]; unsubscribeTopic(id: string): void; isBootstrapOrRelayPeer(pid: string): boolean; disconnectPeer(pid: string): Promise; + pruneConfiguredBootstrapPeer(pid: string): void; } function makeMockNet(): MockNet { @@ -26,6 +28,7 @@ function makeMockNet(): MockNet { unsubscribed: [], disconnected: [], bootstrapOrRelay: new Set(), + prunedBootstrap: [], getTopicPeers(id) { return this.topicPeers.get(id) ?? []; }, @@ -40,16 +43,19 @@ function makeMockNet(): MockNet { async disconnectPeer(pid) { this.disconnected.push(pid); }, + pruneConfiguredBootstrapPeer(pid) { + this.prunedBootstrap.push(pid); + }, }; } -function makeNetworks(net: MockNet, joined: string[]): Networks { +// bootstrapPeers per network id, exposed to the class via `get`. +function makeNetworks(net: MockNet, joined: string[], configs: Record = {}): Networks { const networks = Object.create(Networks.prototype) as Networks; (networks as any).network = net; (networks as any).joinedNetworks = new Set(joined); (networks as any)._onNetworkLeft = null; - // leaveNetwork resolves the lishnet name only for logging — no DB here. - (networks as any).get = () => undefined; + (networks as any).get = (id: string) => (configs[id] ? { networkID: id, bootstrapPeers: configs[id] } : undefined); return networks; } @@ -108,4 +114,16 @@ describe('Networks.leaveNetwork — exclusive peer disconnect', () => { expect(leftIDs).toEqual(['net-a']); expect((networks as any).joinedNetworks.has('net-a')).toBe(false); }); + + it('prunes bootstrap exemption for peers configured only for the left lishnet', async () => { + net.topicPeers.set('net-a', []); + const networks = makeNetworks(net, ['net-a', 'net-b'], { + 'net-a': ['/ip4/192.0.2.1/tcp/9090/p2p/pOnlyA', '/ip4/192.0.2.2/tcp/9090/p2p/pShared'], + 'net-b': ['/ip4/192.0.2.3/tcp/9090/p2p/pShared'], + }); + await leave(networks, 'net-a'); + // pOnlyA is bootstrap only for the left network → exemption pruned. + // pShared is still bootstrap for the joined net-b → exemption kept. + expect(net.prunedBootstrap).toEqual(['pOnlyA']); + }); }); From 0cac33729fa1629c5152394e7073a21bd51888ce Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 16:35:46 +0200 Subject: [PATCH 24/49] fix(network): use final p2p id for relayed bootstrap exemption --- backend/src/lishnet/lishnets.ts | 8 ++++++-- backend/src/protocol/network.ts | 7 ++++++- backend/tests/unit/lishnet/leave-network.test.ts | 11 +++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index a5264584..6fd52d16 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -147,8 +147,12 @@ export class Networks { private static bootstrapPeerIDsOf(bootstrapPeers: string[]): string[] { const ids: string[] = []; for (const addr of bootstrapPeers) { - const m = addr.match(/\/p2p\/([^/]+)/); - if (m) ids.push(m[1]!); + // Relayed multiaddrs (.../p2p//p2p-circuit/p2p/) carry two + // /p2p components; the bootstrap peer identity is the FINAL one (the target), + // not the relay. Match all and take the last. + const matches = [...addr.matchAll(/\/p2p\/([^/]+)/g)]; + const last = matches[matches.length - 1]; + if (last) ids.push(last[1]!); } return ids; } diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 66038a16..59ecaafd 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -955,7 +955,12 @@ export class Network { trace(`[NET] addBootstrapPeers skip non-routable: ${peer}`); continue; } - const peerID = ma.getComponents().find(c => c.code === 421)?.value ?? null; + // A relayed bootstrap multiaddr (.../p2p//p2p-circuit/p2p/) + // carries two /p2p components; the peer we actually connect to — and must + // exempt from leave-network disconnect — is the FINAL one (the target), not + // the relay. Take the last /p2p component, never the first. + const p2pComponents = ma.getComponents().filter(c => c.code === 421); + const peerID = (p2pComponents.length > 0 ? p2pComponents[p2pComponents.length - 1]!.value : null) ?? null; if (peerID && origin === 'configured') this.configuredBootstrapPeerIDs.add(peerID); const alreadyKnown = !!peerID && this.bootstrapPeerIDs.has(peerID); if (peerID && !alreadyKnown) { diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index 37143e1f..719c6bad 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -126,4 +126,15 @@ describe('Networks.leaveNetwork — exclusive peer disconnect', () => { // pShared is still bootstrap for the joined net-b → exemption kept. expect(net.prunedBootstrap).toEqual(['pOnlyA']); }); + + it('prunes the final /p2p target id for a relayed bootstrap multiaddr, not the relay', async () => { + net.topicPeers.set('net-a', []); + const networks = makeNetworks(net, ['net-a'], { + // Relayed entry: /p2p//p2p-circuit/p2p/. The bootstrap + // identity is the target (final /p2p), never the relay. + 'net-a': ['/ip4/192.0.2.10/tcp/9090/p2p/pRelay/p2p-circuit/p2p/pTarget'], + }); + await leave(networks, 'net-a'); + expect(net.prunedBootstrap).toEqual(['pTarget']); + }); }); From f151caffefe77bf3428e667b03546fe6733c95ef Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 16:36:41 +0200 Subject: [PATCH 25/49] fix(downloader): dispose peer-disconnect handler on terminal error --- backend/src/protocol/downloader.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index 8821b309..e16164e8 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -188,6 +188,12 @@ export class Downloader { this.clearRetryTimer(); this.clearPeerDiscoveryTimer(); if (this.lishID) unregisterHaveAnnouncementHandler(this.lishID); + // Terminal error is not always followed by destroy() — the transfer layer + // drops an errored downloader from its map without destroying it. Dispose the + // network peer:disconnect subscription here too (mirrors the HAVE handler + // unregister above), otherwise the handler closure leaks and pins this object. + this.peerDisconnectDisposer?.(); + this.peerDisconnectDisposer = undefined; // Fire-and-forget close — stream may already be reset/aborted, which is benign. // Log at trace so real bugs (e.g. TypeError) can still be spotted in debug logs. this.peerManager.closeAll('setError'); From 97bbabfac39b41f5469b473fbf4a9848d2bf4d76 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 16:39:27 +0200 Subject: [PATCH 26/49] fix(network): suppress left peers from redial maintenance --- backend/src/protocol/network.ts | 29 ++++++++++-- .../unit/protocol/network-disconnect.test.ts | 47 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 59ecaafd..05785ca2 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -201,6 +201,16 @@ export class Network { */ private readonly redialBackoff = new Map(); + /** + * Peers deliberately hung up by {@link disconnectPeer} (leave-network) that + * redial maintenance must NOT proactively re-dial — otherwise a peer we just + * left is re-connected within one status tick (~30s), defeating the leave. + * Cleared the moment the peer is observed connected again by any legitimate + * path (its own re-dial, or mesh reconnection after we re-join). Pruned when + * the peer leaves the peerStore so the set stays bounded. + */ + private readonly redialSuppressed = new Set(); + // Tracked libp2p/pubsub event listeners for clean removal in stop(). // Each entry captures the exact handler reference so removeEventListener can unhook it. private listeners: Array<{ target: EventTarget; event: string; handler: (evt: any) => void }> = []; @@ -731,11 +741,19 @@ export class Network { const candidates: Array<{ peer: any; pid: string; addrSummary: string; failCount: number }> = []; let skippedBackoff = 0; let skippedNoReachable = 0; + let skippedSuppressed = 0; const localCidrs = getLocalCidrs(now); for (const peer of allPeers) { const pid = peer.id.toString(); if (connectedSet.has(pid)) { this.redialBackoff.delete(pid); // clear on observed connection + this.redialSuppressed.delete(pid); // legitimately reconnected → resume maintenance + continue; + } + // Skip peers we deliberately left (leave-network) so maintenance does not + // silently re-dial them; cleared above once they reconnect on their own. + if (this.redialSuppressed.has(pid)) { + skippedSuppressed++; continue; } const bo = this.redialBackoff.get(pid); @@ -804,12 +822,13 @@ export class Network { }; const workers = Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, () => worker()); await Promise.all(workers); - if (candidates.length > 0 || skippedBackoff > 0 || skippedNoReachable > 0) { - console.debug(` Re-dial: ${redialSuccess}/${candidates.length} succeeded (${skippedBackoff} skipped by backoff, ${skippedNoReachable} skipped no-reachable-addrs)`); + if (candidates.length > 0 || skippedBackoff > 0 || skippedNoReachable > 0 || skippedSuppressed > 0) { + console.debug(` Re-dial: ${redialSuccess}/${candidates.length} succeeded (${skippedBackoff} skipped by backoff, ${skippedNoReachable} skipped no-reachable-addrs, ${skippedSuppressed} skipped left-peer)`); } - // Prune backoff entries for peers that are no longer in peerStore to prevent unbounded growth. + // Prune backoff / suppression entries for peers no longer in peerStore to prevent unbounded growth. const storeSet = new Set(allPeers.map(p => p.id.toString())); for (const pid of this.redialBackoff.keys()) if (!storeSet.has(pid)) this.redialBackoff.delete(pid); + for (const pid of this.redialSuppressed) if (!storeSet.has(pid)) this.redialSuppressed.delete(pid); } private async runZeroConnectionRecovery(connectedPeers: any[]): Promise { @@ -1172,6 +1191,9 @@ export class Network { } catch (err: any) { trace(`[NET] disconnectPeer: hangUp failed for ${peerID.slice(0, 16)}: ${err?.message ?? err}`); } + // Keep redial maintenance from re-dialing this just-left peer on the next + // status tick. Cleared automatically once it reconnects legitimately. + this.redialSuppressed.add(peerID); } /** Snapshot of all per-network bootstrap statuses. */ @@ -1571,6 +1593,7 @@ export class Network { this._lastPeerCounts.clear(); this._lastScores.clear(); this.redialBackoff.clear(); + this.redialSuppressed.clear(); this.pxIngressLogKeys.clear(); if (this.node) { await this.node.stop(); diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts index a636b535..86d1ea6e 100644 --- a/backend/tests/unit/protocol/network-disconnect.test.ts +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'bun:test'; import { KEEP_ALIVE } from '@libp2p/interface'; +import { multiaddr } from '@multiformats/multiaddr'; import { Network } from '../../../src/protocol/network.ts'; /** @@ -15,6 +16,7 @@ function makeNetwork() { const merges: Array<{ tags: Record }> = []; const hungUp: string[] = []; const network = Object.create(Network.prototype) as Network; + (network as any).redialSuppressed = new Set(); (network as any).node = { peerStore: { async merge(_pid: unknown, patch: { tags: Record }): Promise { @@ -56,4 +58,49 @@ describe('Network.disconnectPeer — keep-alive tag removal', () => { expect(merges).toEqual([]); expect(hungUp).toEqual([]); }); + + it('suppresses the hung-up peer from redial maintenance', async () => { + const { network } = makeNetwork(); + await network.disconnectPeer(PEER_ID); + expect((network as any).redialSuppressed.has(PEER_ID)).toBe(true); + }); +}); + +/** + * runRedialMaintenance must not re-dial peers that leave-network just hung up + * (they sit in redialSuppressed), and must drop that suppression the moment the + * peer is observed connected again so normal maintenance resumes. + */ +describe('Network.runRedialMaintenance — leave-peer suppression', () => { + function bareNetwork(suppressed: string[]) { + const dialed: string[] = []; + const network = Object.create(Network.prototype) as Network; + (network as any).redialBackoff = new Map(); + (network as any).redialSuppressed = new Set(suppressed); + (network as any).node = { + async dial(id: { toString(): string }): Promise { + dialed.push(id.toString()); + }, + getConnections: () => [], + }; + return { network, dialed }; + } + + const run = (network: Network, connected: any[], all: any[]): Promise => (network as any).runRedialMaintenance(connected, all); + + it('does not re-dial a peer suppressed by leave-network', async () => { + const { network, dialed } = bareNetwork(['pLeft']); + const peer = { id: { toString: () => 'pLeft' }, addresses: [{ multiaddr: multiaddr('/ip4/203.0.113.5/tcp/9090') }] }; + await run(network, [], [peer]); + expect(dialed).toEqual([]); + expect((network as any).redialSuppressed.has('pLeft')).toBe(true); + }); + + it('clears suppression once the peer is observed connected again', async () => { + const { network, dialed } = bareNetwork(['pBack']); + const peer = { id: { toString: () => 'pBack' } }; + await run(network, [{ toString: () => 'pBack' }], [peer]); + expect(dialed).toEqual([]); + expect((network as any).redialSuppressed.has('pBack')).toBe(false); + }); }); From 7dba87b7b565d3d28183566da7433d3daff0fb7d Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 16:42:38 +0200 Subject: [PATCH 27/49] fix(transfer): resume suspended downloads when lishnet rejoins --- backend/src/api/transfer.ts | 35 +++++++++++++++++++ backend/src/lishnet/lishnets.ts | 18 ++++++++++ .../tests/unit/lishnet/leave-network.test.ts | 35 +++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 7ce9f214..14a04c29 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -115,6 +115,13 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, const activeDownloaders = new Map(); setActiveDownloadersRef(activeDownloaders); + // LISHs whose download was suspended because their last joined lishnet was + // left. Their DB enabled flag stays on (see onNetworkLeft), so they are + // resumed by onNetworkJoined when a bound lishnet is re-joined in-process. + // Cleared when the user explicitly enables/disables the download so a rejoin + // never overrides a deliberate user action. + const networkSuspended = new Set(); + // When a lishnet is left, stop any download bound EXCLUSIVELY to it: a // downloader keeps running as long as at least one of its networks is still // joined (multi-network downloads can still source chunks elsewhere). Only @@ -140,6 +147,9 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // DB flag stays untouched, so an app restart with the lishnet re-joined // resumes the download. downloadEnabledLishs.delete(lishID); + // Remember it as suspended-by-leave so onNetworkJoined can resume it if a + // bound lishnet is re-joined in-process (without waiting for a restart). + networkSuspended.add(lishID); // Cancel any pending error-recovery timer for this LISH — otherwise // ErrorRecovery, holding the captured downloadWasEnabled=true, could // re-enable the download once the IO condition clears even though the @@ -151,6 +161,28 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, } }; + // When a previously-left lishnet is re-joined in-process, resume downloads that + // were suspended because it was their last joined network. Their DB enabled flag + // was intentionally left on (see onNetworkLeft), so re-enabling here restores the + // pre-leave state without waiting for an app restart. Only downloads still bound + // to the re-joined network and still suspended are resumed. + networks.onNetworkJoined = (networkID: string) => { + for (const lishID of networkSuspended) { + const dl = activeDownloaders.get(lishID); + if (!dl) { + // Downloader was destroyed while suspended — drop the stale entry. + networkSuspended.delete(lishID); + continue; + } + if (!dl.getNetworkIDs?.().includes(networkID)) continue; + networkSuspended.delete(lishID); + console.log(`[Transfer] ${lishID.slice(0, 8)}: lishnet re-joined, resuming download`); + enableDownload({ lishID }).catch(err => { + console.error(`[Transfer] resume-on-rejoin ${lishID.slice(0, 8)} failed:`, err?.message ?? err); + }); + } + }; + // Error recovery: auto-retry when IO conditions clear const recovery = new ErrorRecovery({ attemptRecover: async (lishID, downloadWasEnabled, uploadWasEnabled): Promise => { @@ -217,6 +249,7 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, function disableDownload(p: { lishID: string }): { success: boolean } { assert(p, ['lishID']); + networkSuspended.delete(p.lishID); recovery.stop(p.lishID); downloadEnabledLishs.delete(p.lishID); persistDownloadEnabled?.(p.lishID, false); @@ -231,6 +264,7 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, async function enableDownload(p: { lishID: string }, client?: any): Promise<{ success: boolean }> { assert(p, ['lishID']); + networkSuspended.delete(p.lishID); if (isBusy(p.lishID)) return { success: false }; if (pendingDownloads.has(p.lishID)) return { success: true }; dataServer.clearError(p.lishID); @@ -525,6 +559,7 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, } activeDownloaders.clear(); downloadEnabledLishs.clear(); + networkSuspended.clear(); clearAllUploads(); recovery.stopAll(); } diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index 6fd52d16..0a9b5818 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -24,6 +24,9 @@ export class Networks { // Callback fired after a lishnet is left (topic unsubscribed). Lets higher // layers (e.g. transfer) stop downloads bound exclusively to that lishnet. private _onNetworkLeft: ((networkID: string) => void) | null = null; + // Callback fired after a lishnet is (re-)joined in-process. Lets higher layers + // resume downloads that were suspended when this lishnet was previously left. + private _onNetworkJoined: ((networkID: string) => void) | null = null; constructor(db: Database, dataDir: string, dataServer: DataServer, settings: Settings) { this.db = db; @@ -63,6 +66,17 @@ export class Networks { this._onNetworkLeft = cb; } + /** + * Set a callback fired right after a lishnet is (re-)joined via {@link joinNetwork} + * (its topic subscribed and added to {@link joinedNetworks}). Lets higher layers + * (e.g. transfer) resume downloads that were suspended when the lishnet was + * previously left. NOT fired for the initial startup join — startup has its own + * auto-resume path. + */ + set onNetworkJoined(cb: ((networkID: string) => void) | null) { + this._onNetworkJoined = cb; + } + init(): void { console.log('✓ Networks initialized'); } @@ -138,6 +152,10 @@ export class Networks { if (net && net.bootstrapPeers.length > 0) await this.network.addBootstrapPeers(net.bootstrapPeers, id, 'configured'); console.log(`✓ Joined lishnet: ${net?.name ?? id}`); + + // Notify higher layers (e.g. transfer) so downloads suspended when this + // lishnet was last left can resume now that it is joined again. + this._onNetworkJoined?.(id); } /** diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index 719c6bad..e92cd86c 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -12,11 +12,13 @@ import { Networks } from '../../../src/lishnet/lishnets.ts'; interface MockNet { topicPeers: Map; unsubscribed: string[]; + subscribed: string[]; disconnected: string[]; bootstrapOrRelay: Set; prunedBootstrap: string[]; getTopicPeers(id: string): string[]; unsubscribeTopic(id: string): void; + subscribeTopic(id: string): void; isBootstrapOrRelayPeer(pid: string): boolean; disconnectPeer(pid: string): Promise; pruneConfiguredBootstrapPeer(pid: string): void; @@ -26,6 +28,7 @@ function makeMockNet(): MockNet { return { topicPeers: new Map(), unsubscribed: [], + subscribed: [], disconnected: [], bootstrapOrRelay: new Set(), prunedBootstrap: [], @@ -37,6 +40,9 @@ function makeMockNet(): MockNet { // Mirror real pubsub: after unsubscribe the topic reports no peers. this.topicPeers.delete(id); }, + subscribeTopic(id) { + this.subscribed.push(id); + }, isBootstrapOrRelayPeer(pid) { return this.bootstrapOrRelay.has(pid); }, @@ -55,11 +61,13 @@ function makeNetworks(net: MockNet, joined: string[], configs: Record (configs[id] ? { networkID: id, bootstrapPeers: configs[id] } : undefined); return networks; } const leave = (networks: Networks, id: string): Promise => (networks as any).leaveNetwork(id); +const join = (networks: Networks, id: string): Promise => (networks as any).joinNetwork(id); describe('Networks.leaveNetwork — exclusive peer disconnect', () => { let net: MockNet; @@ -138,3 +146,30 @@ describe('Networks.leaveNetwork — exclusive peer disconnect', () => { expect(net.prunedBootstrap).toEqual(['pTarget']); }); }); + +describe('Networks.joinNetwork — onNetworkJoined notification', () => { + let net: MockNet; + + beforeEach(() => { + net = makeMockNet(); + }); + + it('fires onNetworkJoined with the joined lishnet id and joins it', async () => { + const networks = makeNetworks(net, []); + const joinedIDs: string[] = []; + networks.onNetworkJoined = id => joinedIDs.push(id); + await join(networks, 'net-a'); + expect(net.subscribed).toEqual(['net-a']); + expect(joinedIDs).toEqual(['net-a']); + expect((networks as any).joinedNetworks.has('net-a')).toBe(true); + }); + + it('does not fire onNetworkJoined for a lishnet already joined', async () => { + const networks = makeNetworks(net, ['net-a']); + let fired = 0; + networks.onNetworkJoined = () => fired++; + await join(networks, 'net-a'); + expect(fired).toBe(0); + expect(net.subscribed).toEqual([]); + }); +}); From c9b788da35198cd5b5c82d1b534bd296e579428b Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:19:06 +0200 Subject: [PATCH 28/49] fix(network): disconnect offline bootstrap peers of left lishnet --- backend/src/lishnet/lishnets.ts | 16 ++++++--- .../tests/unit/lishnet/leave-network.test.ts | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index 0a9b5818..47e535de 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -200,12 +200,20 @@ export class Networks { this.network.unsubscribeTopic(id); this.joinedNetworks.delete(id); - // Drop the bootstrap-exemption for peers configured only for the lishnet we - // just left. Without this a stale exemption would make the disconnect loop - // below skip a peer that is no longer shared with any joined network. + // Drop the exemption AND actively disconnect every configured bootstrap peer + // exclusive to the left lishnet — including ones offline at leave time. Such + // a peer never appears in leftPeers (the topic-subscriber snapshot), so the + // content-peer loop below would miss it: its keep-alive tag would survive and + // redial maintenance / ReconnectQueue would reconnect it within ~30s. After + // pruning, isBootstrapOrRelayPeer is true only for an active circuit relay we + // still depend on — keep those. disconnectPeer is a safe no-op hangUp for an + // unconnected peer and always strips keep-alive + suppresses redial. const stillConfigured = this.configuredBootstrapPeerIDsElsewhere(id); for (const pid of this.configuredBootstrapPeerIDsOf(id)) { - if (!stillConfigured.has(pid)) this.network.pruneConfiguredBootstrapPeer(pid); + if (stillConfigured.has(pid)) continue; + this.network.pruneConfiguredBootstrapPeer(pid); + if (this.network.isBootstrapOrRelayPeer(pid)) continue; + await this.network.disconnectPeer(pid); } // Disconnect peers that belonged exclusively to the lishnet we just left. diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index e92cd86c..e6f2c0ab 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -145,6 +145,42 @@ describe('Networks.leaveNetwork — exclusive peer disconnect', () => { await leave(networks, 'net-a'); expect(net.prunedBootstrap).toEqual(['pTarget']); }); + + it('disconnects an offline configured bootstrap peer of the left lishnet', async () => { + // pBootA is configured bootstrap for net-a only and is NOT a current topic + // subscriber (offline at leave time). It must still be disconnected so its + // keep-alive tag is stripped and redial maintenance cannot reconnect it. + net.topicPeers.set('net-a', []); + const networks = makeNetworks(net, ['net-a'], { + 'net-a': ['/ip4/192.0.2.5/tcp/9090/p2p/pBootA'], + }); + await leave(networks, 'net-a'); + expect(net.disconnected).toEqual(['pBootA']); + expect(net.prunedBootstrap).toEqual(['pBootA']); + }); + + it('keeps an offline bootstrap peer still configured for another joined lishnet', async () => { + net.topicPeers.set('net-a', []); + const networks = makeNetworks(net, ['net-a', 'net-b'], { + 'net-a': ['/ip4/192.0.2.5/tcp/9090/p2p/pShared'], + 'net-b': ['/ip4/192.0.2.6/tcp/9090/p2p/pShared'], + }); + await leave(networks, 'net-a'); + expect(net.disconnected).toEqual([]); + expect(net.prunedBootstrap).toEqual([]); + }); + + it('keeps a left-lishnet bootstrap peer that is still an active circuit relay', async () => { + net.topicPeers.set('net-a', []); + net.bootstrapOrRelay.add('pRelayNode'); // still relaying another connection + const networks = makeNetworks(net, ['net-a'], { + 'net-a': ['/ip4/192.0.2.7/tcp/9090/p2p/pRelayNode'], + }); + await leave(networks, 'net-a'); + expect(net.disconnected).toEqual([]); + // Exemption is still pruned — the relay status alone keeps it connected. + expect(net.prunedBootstrap).toEqual(['pRelayNode']); + }); }); describe('Networks.joinNetwork — onNetworkJoined notification', () => { From 55113862a20c0744d413f394d89886e19e12babe Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:20:53 +0200 Subject: [PATCH 29/49] fix(downloader): dispose network handlers on successful completion --- backend/src/protocol/downloader.ts | 29 ++++++++++++------- .../tests/unit/protocol/downloader.test.ts | 12 ++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index e16164e8..bfec04f0 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -187,13 +187,7 @@ export class Downloader { this.errorDetail = detail; this.clearRetryTimer(); this.clearPeerDiscoveryTimer(); - if (this.lishID) unregisterHaveAnnouncementHandler(this.lishID); - // Terminal error is not always followed by destroy() — the transfer layer - // drops an errored downloader from its map without destroying it. Dispose the - // network peer:disconnect subscription here too (mirrors the HAVE handler - // unregister above), otherwise the handler closure leaks and pins this object. - this.peerDisconnectDisposer?.(); - this.peerDisconnectDisposer = undefined; + this.disposeNetworkHandlers(); // Fire-and-forget close — stream may already be reset/aborted, which is benign. // Log at trace so real bugs (e.g. TypeError) can still be spotted in debug logs. this.peerManager.closeAll('setError'); @@ -281,6 +275,19 @@ export class Downloader { return this.disabled; } + /** + * Idempotently release network-level subscriptions: the peer:disconnect handler + * and the unicast HAVE announcement handler. Called from every terminal path + * (downloaded / error / destroy); a second call is a no-op. Without this a + * completed/errored downloader dropped from the transfer map (never destroyed) + * would pin itself and its peer/handler closures for the process lifetime. + */ + private disposeNetworkHandlers(): void { + this.peerDisconnectDisposer?.(); + this.peerDisconnectDisposer = undefined; + if (this.lishID) unregisterHaveAnnouncementHandler(this.lishID); + } + async destroy(): Promise { console.debug(`[DL] destroy ${this.lishID.slice(0, 8)}, state=${this.state}, peers=${this.peerManager.size()}`); this.disabled = true; @@ -288,9 +295,7 @@ export class Downloader { this.abortController.abort(); this.clearRetryTimer(); this.clearPeerDiscoveryTimer(); - this.peerDisconnectDisposer?.(); - this.peerDisconnectDisposer = undefined; - if (this.lishID) unregisterHaveAnnouncementHandler(this.lishID); + this.disposeNetworkHandlers(); await this.peerManager.closeAllAwait('destroy'); // Notify frontend to reset peers/speed immediately const total = this.dataServer.getAllChunkCount(this.lishID) || 0; @@ -422,6 +427,10 @@ export class Downloader { this.downloadReject = reject; }); } + // Terminal success: release network subscriptions. The transfer layer drops a + // completed downloader from its map without calling destroy(), so this is the + // only place the handlers get torn down on the happy path. + this.disposeNetworkHandlers(); } async doWork(): Promise { diff --git a/backend/tests/unit/protocol/downloader.test.ts b/backend/tests/unit/protocol/downloader.test.ts index e135e98c..168189c6 100644 --- a/backend/tests/unit/protocol/downloader.test.ts +++ b/backend/tests/unit/protocol/downloader.test.ts @@ -1340,4 +1340,16 @@ describe('Downloader – network peer:disconnect handling', () => { await downloader.destroy(); expect(net.peerDisconnectHandlers.size).toBe(0); }); + + it('successful completion disposes the peer:disconnect subscription', async () => { + expect(net.peerDisconnectHandlers.size).toBe(1); + // needsManifest path parks download() on its internal completion promise + // without touching the filesystem; resolve it to simulate a finished download. + const done = downloader.download(); + while (!priv(downloader)['downloadResolve']) await new Promise(r => setTimeout(r, 0)); + priv(downloader)['state'] = 'downloaded'; + (priv(downloader)['downloadResolve'] as () => void)(); + await done; + expect(net.peerDisconnectHandlers.size).toBe(0); + }); }); From 709e03d2c5140e7ed49be4288ac5c1b1f5983731 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:21:25 +0200 Subject: [PATCH 30/49] fix(transfer): keep DB enabled flag when no lishnet is joined --- backend/src/api/transfer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 14a04c29..45266bff 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -338,8 +338,12 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, const network = networks.getRunningNetwork(); const joinedNetworks = networks.getEnabled().map(n => n.networkID); if (joinedNetworks.length === 0) { + // No lishnet is joined to source this download. Keep the DB enabled flag + // ON — clearing it would permanently forget the user's intent so a later + // rejoin could never resume. Drop only the in-memory active flag and mark + // it suspended so onNetworkJoined resumes it once a lishnet is (re-)joined. downloadEnabledLishs.delete(p.lishID); - persistDownloadEnabled?.(p.lishID, false); + networkSuspended.add(p.lishID); return { success: false }; } const downloadDir = lish.directory ?? join(dataDir, 'downloads', Date.now().toString()); From 6d6189ed73fc001f8bbb3686533b3f51ccca1bf9 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:22:36 +0200 Subject: [PATCH 31/49] fix(transfer): drop resume suspension only after enable succeeds --- backend/src/api/transfer.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 45266bff..dab21ea3 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -167,19 +167,23 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // pre-leave state without waiting for an app restart. Only downloads still bound // to the re-joined network and still suspended are resumed. networks.onNetworkJoined = (networkID: string) => { - for (const lishID of networkSuspended) { + // Drop the suspension ONLY once the resume actually succeeds — a transient + // failure (busy verifying, still no joined lishnet) must be retried on the next + // join, otherwise the resume is lost forever. A retained (disabled) downloader + // is resumed only when the rejoined network is one it sources from; a suspended + // entry without a downloader (e.g. startup with no joined lishnet) is attempted + // directly (enableDownload rebinds it to the currently joined lishnets). + for (const lishID of [...networkSuspended]) { const dl = activeDownloaders.get(lishID); - if (!dl) { - // Downloader was destroyed while suspended — drop the stale entry. - networkSuspended.delete(lishID); - continue; - } - if (!dl.getNetworkIDs?.().includes(networkID)) continue; - networkSuspended.delete(lishID); - console.log(`[Transfer] ${lishID.slice(0, 8)}: lishnet re-joined, resuming download`); - enableDownload({ lishID }).catch(err => { - console.error(`[Transfer] resume-on-rejoin ${lishID.slice(0, 8)} failed:`, err?.message ?? err); - }); + if (dl && !dl.getNetworkIDs?.().includes(networkID)) continue; + enableDownload({ lishID }) + .then(r => { + if (r.success) { + networkSuspended.delete(lishID); + console.log(`[Transfer] ${lishID.slice(0, 8)}: lishnet re-joined, download resumed`); + } + }) + .catch(err => console.error(`[Transfer] resume-on-rejoin ${lishID.slice(0, 8)} failed:`, err?.message ?? err)); } }; @@ -264,7 +268,6 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, async function enableDownload(p: { lishID: string }, client?: any): Promise<{ success: boolean }> { assert(p, ['lishID']); - networkSuspended.delete(p.lishID); if (isBusy(p.lishID)) return { success: false }; if (pendingDownloads.has(p.lishID)) return { success: true }; dataServer.clearError(p.lishID); From 20dedfbf1832de99cb1ff8b57d1f5ff836cb78ef Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:24:12 +0200 Subject: [PATCH 32/49] fix(downloader): re-attach left network to running download on rejoin --- backend/src/api/transfer.ts | 3 +++ backend/src/protocol/downloader.ts | 20 +++++++++++++++- .../downloader-remove-network.test.ts | 23 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index dab21ea3..6e9e72c2 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -167,6 +167,9 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // pre-leave state without waiting for an app restart. Only downloads still bound // to the re-joined network and still suspended are resumed. networks.onNetworkJoined = (networkID: string) => { + // Re-attach the rejoined network to still-running multi-network downloaders + // that dropped it when it was left (no-op if never bound to it or already active). + for (const dl of activeDownloaders.values()) dl.addNetwork?.(networkID); // Drop the suspension ONLY once the resume actually succeeds — a transient // failure (busy verifying, still no joined lishnet) must be retried on the next // join, otherwise the resume is lost forever. A retained (disabled) downloader diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index bfec04f0..5ac6df90 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -58,6 +58,10 @@ export class Downloader { private network: Network; private readonly downloadDir: string; private networkIDs: string[]; + // Immutable snapshot of the networks this download was created with. removeNetwork + // mutates networkIDs when a lishnet is left; addNetwork consults this to re-attach + // only networks the download was originally bound to when they are re-joined. + private readonly originalNetworkIDs: string[]; private lishID!: LISHid; private state: State = 'added'; private workMutex = new Mutex(); @@ -131,6 +135,18 @@ export class Downloader { this.networkIDs = this.networkIDs.filter(id => id !== networkID); } + /** + * Re-attach a lishnet dropped by {@link removeNetwork} when it was left, now that + * it is joined again — so WANT broadcasts and topic probes reach it once more on + * the next discovery cycle. No-op if the download was never bound to it (not in + * the original set) or it is already active. + */ + addNetwork(networkID: string): void { + if (!this.originalNetworkIDs.includes(networkID)) return; + if (this.networkIDs.includes(networkID)) return; + this.networkIDs = [...this.networkIDs, networkID]; + } + /** * Central state mutation. Validates the requested transition against ALLOWED_TRANSITIONS * and logs a warning (and bails) if the transition is not allowed. @@ -347,7 +363,9 @@ export class Downloader { this.downloadDir = downloadDir; this.network = network; this.dataServer = dataServer; - this.networkIDs = Array.isArray(networkIDs) ? networkIDs : [networkIDs]; + const ids = Array.isArray(networkIDs) ? [...networkIDs] : [networkIDs]; + this.networkIDs = ids; + this.originalNetworkIDs = [...ids]; this.fileAllocator = new FileAllocator(downloadDir); } diff --git a/backend/tests/unit/protocol/downloader-remove-network.test.ts b/backend/tests/unit/protocol/downloader-remove-network.test.ts index 18eeec05..06cfd7d1 100644 --- a/backend/tests/unit/protocol/downloader-remove-network.test.ts +++ b/backend/tests/unit/protocol/downloader-remove-network.test.ts @@ -11,6 +11,7 @@ import { Downloader } from '../../../src/protocol/downloader.ts'; function makeDownloader(networkIDs: string[]): Downloader { const dl = Object.create(Downloader.prototype) as Downloader; (dl as any).networkIDs = [...networkIDs]; + (dl as any).originalNetworkIDs = [...networkIDs]; return dl; } @@ -33,3 +34,25 @@ describe('Downloader.removeNetwork', () => { expect(dl.getNetworkIDs()).toEqual(['net-a', 'net-b']); }); }); + +describe('Downloader.addNetwork', () => { + it('re-adds a network that was previously removed', () => { + const dl = makeDownloader(['net-a', 'net-b']); + dl.removeNetwork('net-a'); + expect(dl.getNetworkIDs()).toEqual(['net-b']); + dl.addNetwork('net-a'); + expect(dl.getNetworkIDs()).toEqual(['net-b', 'net-a']); + }); + + it('is a no-op for a network never in the original set', () => { + const dl = makeDownloader(['net-a', 'net-b']); + dl.addNetwork('net-c'); + expect(dl.getNetworkIDs()).toEqual(['net-a', 'net-b']); + }); + + it('is a no-op when the network is already active', () => { + const dl = makeDownloader(['net-a', 'net-b']); + dl.addNetwork('net-a'); + expect(dl.getNetworkIDs()).toEqual(['net-a', 'net-b']); + }); +}); From 76ab8f351f47d5d02ca8f7c07495b1877443026c Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:30:35 +0200 Subject: [PATCH 33/49] fix(network): consult redial suppression in all maintenance dial paths --- backend/src/protocol/network.ts | 15 +++++++- .../unit/protocol/network-disconnect.test.ts | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 05785ca2..70867dcd 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -731,6 +731,15 @@ export class Network { // churn at N≈100 without flooding logs or burning CPU on per-second probes. } + /** + * Whether a peer was deliberately hung up by leave-network (via disconnectPeer) + * and must NOT be proactively re-dialed by any maintenance path — redial loop, + * zero-connection recovery, or periodic promote — until it reconnects on its own. + */ + private isRedialSuppressed(peerID: string): boolean { + return this.redialSuppressed.has(peerID); + } + private async runRedialMaintenance(connectedPeers: any[], allPeers: any[]): Promise { // Dial known peers not currently connected (maintains relay connections to NATed peers) const connectedSet = new Set(connectedPeers.map(p => p.toString())); @@ -752,7 +761,7 @@ export class Network { } // Skip peers we deliberately left (leave-network) so maintenance does not // silently re-dial them; cleared above once they reconnect on their own. - if (this.redialSuppressed.has(pid)) { + if (this.isRedialSuppressed(pid)) { skippedSuppressed++; continue; } @@ -853,6 +862,9 @@ export class Network { console.log(` [NET-CHURN] bootstrap stats net=${networkID.slice(0, 8)}: ${parts}`); } for (const ma of this.bootstrapMultiaddrs) { + const p2pComponents = ma.getComponents().filter((c: { code: number; value?: string }) => c.code === 421); + const pid: string | undefined = p2pComponents.length > 0 ? p2pComponents[p2pComponents.length - 1].value : undefined; + if (pid && this.isRedialSuppressed(pid)) continue; // deliberately left — don't resurrect it here const maStr = ma?.toString?.() ?? String(ma); try { console.log(` → Dialing ${maStr}`); @@ -895,6 +907,7 @@ export class Network { for (const peer of allPeers) { const pid = peer.id.toString(); if (pid === myID) continue; + if (this.isRedialSuppressed(pid)) continue; // deliberately left — don't promote it back to bootstrap if (this.bootstrapPeerIDs.has(pid)) continue; if (peer.addresses.length === 0) continue; const addr = peer.addresses[0]!; diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts index 86d1ea6e..a1dd5684 100644 --- a/backend/tests/unit/protocol/network-disconnect.test.ts +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -104,3 +104,41 @@ describe('Network.runRedialMaintenance — leave-peer suppression', () => { expect((network as any).redialSuppressed.has('pBack')).toBe(false); }); }); + +/** + * Zero-connection recovery dials bootstrapMultiaddrs when the node has no + * connections. It must skip peers leave-network deliberately hung up, or a left + * bootstrap comes straight back the moment connections briefly hit zero. + */ +describe('Network.runZeroConnectionRecovery — leave-peer suppression', () => { + function bareNetwork(suppressed: string[], bootstrapMaStrs: string[]) { + const dialed: string[] = []; + const network = Object.create(Network.prototype) as Network; + (network as any).redialSuppressed = new Set(suppressed); + (network as any).bootstrapMultiaddrs = bootstrapMaStrs.map(s => multiaddr(s)); + (network as any).recentDisconnects = []; + (network as any).bootstrapTracker = { entries: () => [] }; + (network as any).node = { + async dial(ma: { toString(): string }): Promise { + dialed.push(ma.toString()); + }, + }; + return { network, dialed }; + } + + const run = (network: Network, connected: any[]): Promise => (network as any).runZeroConnectionRecovery(connected); + + it('does not dial a bootstrap peer suppressed by leave-network', async () => { + const ma = `/ip4/192.0.2.1/tcp/9090/p2p/${PEER_ID}`; + const { network, dialed } = bareNetwork([PEER_ID], [ma]); + await run(network, []); + expect(dialed).toEqual([]); + }); + + it('still dials a non-suppressed bootstrap peer', async () => { + const ma = `/ip4/192.0.2.1/tcp/9090/p2p/${PEER_ID}`; + const { network, dialed } = bareNetwork([], [ma]); + await run(network, []); + expect(dialed).toEqual([multiaddr(ma).toString()]); + }); +}); From 709c3d3d7746f1fa35fcea88ad8be6445b2eea18 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:31:38 +0200 Subject: [PATCH 34/49] fix(protocol): fail closed on serve-gate when remote peer id unknown --- backend/src/protocol/lish-protocol.ts | 20 +++++++++++-- .../tests/unit/protocol/serve-gate.test.ts | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 backend/tests/unit/protocol/serve-gate.test.ts diff --git a/backend/src/protocol/lish-protocol.ts b/backend/src/protocol/lish-protocol.ts index 93082d1d..e94265c0 100644 --- a/backend/src/protocol/lish-protocol.ts +++ b/backend/src/protocol/lish-protocol.ts @@ -354,6 +354,20 @@ export function clearAllUploads(): void { const IO_ERROR_THRESHOLD = 3; // consecutive I/O errors before auto-disabling upload +/** + * Serve-gate predicate for the unicast LISH protocol. Returns true when a served + * response (LISH list / manifest / chunk) must be withheld because we do not share + * a joined lishnet with the remote peer. Fail CLOSED: when the gate is active + * (`sharesNetworkWith` provided) but the stream could not be mapped to a peer id + * (`remotePeerID` absent), block exactly like a not-shared peer — a bare or + * unauthenticated transport connection must never be served. Mirrors handleWant, + * which drops a WANT that has no verified sender peerID. + */ +export function serveGateBlocks(sharesNetworkWith: ((peerID: string) => boolean) | undefined, remotePeerID: string | undefined): boolean { + if (!sharesNetworkWith) return false; + return !remotePeerID || !sharesNetworkWith(remotePeerID); +} + export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, remotePeerID?: string, connectionType?: ConnectionType, sharesNetworkWith?: (peerID: string) => boolean): Promise { const servedLishIDs = new Set(); const ioErrorCounts = new Map(); // per-LISH consecutive I/O error counter @@ -396,7 +410,7 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, // Serve the shared-LISH list only to peers we share a joined // lishnet with. A bare transport connection (e.g. the peer re-dialed // us right after we left its network) must not reveal what we share. - if (sharesNetworkWith && remotePeerID && !sharesNetworkWith(remotePeerID)) { + if (serveGateBlocks(sharesNetworkWith, remotePeerID)) { trace(`[PROTO] getLishs from ${remotePeer} refused: no shared joined lishnet`); const gated: LISHGetLishsResponse = { type: 'getLishs-result', lishs: [] }; sendLengthPrefixed(stream, codecEncode(gated)); @@ -426,7 +440,7 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, } else if (request.type === 'getLish') { // Only return manifest for LISHs with upload enabled — and only to // peers we share a joined lishnet with (same gate as getLishs). - if ((sharesNetworkWith && remotePeerID && !sharesNetworkWith(remotePeerID)) || !isUploadAdvertisable(request.lishID)) { + if (serveGateBlocks(sharesNetworkWith, remotePeerID) || !isUploadAdvertisable(request.lishID)) { const response: LISHGetLishResponse = { error: ErrorCodes.PEER_LISH_NOT_SHARED }; sendLengthPrefixed(stream, codecEncode(response)); } else { @@ -450,7 +464,7 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, // lishnet with. Without this a downloader re-dials our stored // address after we left its network and the transfer silently // continues over the fresh transport connection. - if (sharesNetworkWith && remotePeerID && !sharesNetworkWith(remotePeerID)) { + if (serveGateBlocks(sharesNetworkWith, remotePeerID)) { trace(`[PROTO] getChunk from ${remotePeer} refused: no shared joined lishnet`); const gatedResponse: LISHGetChunkResponse = { error: ErrorCodes.PEER_LISH_NOT_SHARED }; sendLengthPrefixed(stream, codecEncode(gatedResponse)); diff --git a/backend/tests/unit/protocol/serve-gate.test.ts b/backend/tests/unit/protocol/serve-gate.test.ts new file mode 100644 index 00000000..b8a62957 --- /dev/null +++ b/backend/tests/unit/protocol/serve-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'bun:test'; +import { serveGateBlocks } from '../../../src/protocol/lish-protocol.ts'; + +/** + * The unicast LISH serve-gate must fail CLOSED: a stream that could not be mapped + * to a peer id (remotePeerID absent) is refused exactly like a not-shared peer, + * never served. Only when no gate is configured (sharesNetworkWith undefined) is + * everything allowed. + */ +describe('serveGateBlocks', () => { + it('does not block when no gate is configured', () => { + expect(serveGateBlocks(undefined, 'peer-a')).toBe(false); + expect(serveGateBlocks(undefined, undefined)).toBe(false); + }); + + it('does not block a peer we share a joined lishnet with', () => { + expect(serveGateBlocks(() => true, 'peer-a')).toBe(false); + }); + + it('blocks a peer we do not share a joined lishnet with', () => { + expect(serveGateBlocks(() => false, 'peer-a')).toBe(true); + }); + + it('blocks (fail closed) when the remote peer id is unknown', () => { + expect(serveGateBlocks(() => true, undefined)).toBe(true); + expect(serveGateBlocks(() => true, '')).toBe(true); + }); +}); From fe1591297eac33d5298ce7baa40af3211b1f3d1c Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:52:10 +0200 Subject: [PATCH 35/49] fix(network): lift redial suppression when bootstrap peer is re-configured --- backend/src/protocol/network.ts | 9 ++++- .../unit/protocol/network-disconnect.test.ts | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 70867dcd..347ecfef 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -993,7 +993,14 @@ export class Network { // the relay. Take the last /p2p component, never the first. const p2pComponents = ma.getComponents().filter(c => c.code === 421); const peerID = (p2pComponents.length > 0 ? p2pComponents[p2pComponents.length - 1]!.value : null) ?? null; - if (peerID && origin === 'configured') this.configuredBootstrapPeerIDs.add(peerID); + if (peerID && origin === 'configured') { + this.configuredBootstrapPeerIDs.add(peerID); + // A re-configured bootstrap peer means its network was (re-)joined — it + // is no longer "left", so lift any redial suppression left by a prior + // leaveNetwork, otherwise maintenance would skip it forever if this one + // explicit dial fails or the connection drops before the next tick. + this.redialSuppressed.delete(peerID); + } const alreadyKnown = !!peerID && this.bootstrapPeerIDs.has(peerID); if (peerID && !alreadyKnown) { this.bootstrapPeerIDs.add(peerID); diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts index a1dd5684..0c2b7038 100644 --- a/backend/tests/unit/protocol/network-disconnect.test.ts +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -142,3 +142,38 @@ describe('Network.runZeroConnectionRecovery — leave-peer suppression', () => { expect(dialed).toEqual([multiaddr(ma).toString()]); }); }); + +/** + * Re-configuring a bootstrap peer (network re-join) must lift any redial + * suppression left by a prior leaveNetwork — otherwise maintenance skips it + * forever if the single explicit join-dial fails or drops before the next tick. + */ +describe('Network.addBootstrapPeers — rejoin clears suppression', () => { + function bareNetwork(suppressed: string[]) { + const network = Object.create(Network.prototype) as Network; + (network as any).redialSuppressed = new Set(suppressed); + (network as any).configuredBootstrapPeerIDs = new Set(); + (network as any).bootstrapPeerIDs = new Set(); + (network as any).bootstrapMultiaddrs = []; + (network as any).bootstrapTracker = { markPending() {}, recordOutcome() {} }; + (network as any).node = { + peerId: { toString: () => 'selfID' }, + getConnections: () => [], + async dial(): Promise {}, + peerStore: { async merge(): Promise {} }, + }; + return network; + } + + it('lifts suppression for a re-configured bootstrap peer', async () => { + const network = bareNetwork([PEER_ID]); + await (network as any).addBootstrapPeers([`/ip4/192.0.2.1/tcp/9090/p2p/${PEER_ID}`], 'net-a', 'configured'); + expect((network as any).redialSuppressed.has(PEER_ID)).toBe(false); + }); + + it('does not lift suppression for a discovered (non-configured) re-add', async () => { + const network = bareNetwork([PEER_ID]); + await (network as any).addBootstrapPeers([`/ip4/192.0.2.1/tcp/9090/p2p/${PEER_ID}`], 'net-a', 'discovered'); + expect((network as any).redialSuppressed.has(PEER_ID)).toBe(true); + }); +}); From b8e90dc43c06b86a4fef56d7b5e93ad674d53d70 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:52:36 +0200 Subject: [PATCH 36/49] fix(network): skip discovery re-dial for left-suppressed peers --- backend/src/protocol/network.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 347ecfef..4f4fd9cf 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -519,6 +519,10 @@ export class Network { // Skip if already connected (autoDial in v2 is unreliable; we dial actively // for mDNS/bootstrap discoveries to ensure local peers form a mesh quickly). if (peerID === this.node!.peerId.toString()) return; + // A peer we deliberately left (leave-network) must not be re-tagged or + // re-dialed by discovery (mDNS/identify/PX) — that would beat the disconnect. + // Suppression lifts on a legitimate inbound reconnect or on network rejoin. + if (this.isRedialSuppressed(peerID)) return; // Stamp `keep-alive-fleet` on every discovered peer, regardless of how they // surfaced (mDNS, bootstrap, autonat, identify, peer-announce). libp2p // ReconnectQueue only acts on peers with a tag whose key starts with From c4a1caa289ce54b045ef7ceee55dfba7df1bba25 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:53:35 +0200 Subject: [PATCH 37/49] fix(network): keep left-net bootstrap peer subscribing another joined net --- backend/src/lishnet/lishnets.ts | 22 ++++++++++++------- .../tests/unit/lishnet/leave-network.test.ts | 11 ++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index 47e535de..e5f5a706 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -200,18 +200,28 @@ export class Networks { this.network.unsubscribeTopic(id); this.joinedNetworks.delete(id); + // Subscribers of any OTHER joined lishnet must stay connected (shared + // infrastructure). Compute this set BEFORE the bootstrap cleanup so that loop + // can skip them too — a bootstrap of the left net that also subscribes another + // joined net would otherwise be hung up here. + const stillJoinedPeers = new Set(); + for (const otherID of this.joinedNetworks) { + for (const pid of this.network.getTopicPeers(otherID)) stillJoinedPeers.add(pid); + } + // Drop the exemption AND actively disconnect every configured bootstrap peer // exclusive to the left lishnet — including ones offline at leave time. Such // a peer never appears in leftPeers (the topic-subscriber snapshot), so the // content-peer loop below would miss it: its keep-alive tag would survive and - // redial maintenance / ReconnectQueue would reconnect it within ~30s. After - // pruning, isBootstrapOrRelayPeer is true only for an active circuit relay we - // still depend on — keep those. disconnectPeer is a safe no-op hangUp for an - // unconnected peer and always strips keep-alive + suppresses redial. + // redial maintenance / ReconnectQueue would reconnect it within ~30s. Keep it + // if it still subscribes another joined lishnet, or if it is an active circuit + // relay we depend on. disconnectPeer is a safe no-op hangUp for an unconnected + // peer and always strips keep-alive + suppresses redial. const stillConfigured = this.configuredBootstrapPeerIDsElsewhere(id); for (const pid of this.configuredBootstrapPeerIDsOf(id)) { if (stillConfigured.has(pid)) continue; this.network.pruneConfiguredBootstrapPeer(pid); + if (stillJoinedPeers.has(pid)) continue; if (this.network.isBootstrapOrRelayPeer(pid)) continue; await this.network.disconnectPeer(pid); } @@ -223,10 +233,6 @@ export class Networks { // remaining reason to stay connected, so hang it up via the single // Network.disconnectPeer entry point (which also clears the keep-alive tag // so ReconnectQueue does not immediately re-dial it). - const stillJoinedPeers = new Set(); - for (const otherID of this.joinedNetworks) { - for (const pid of this.network.getTopicPeers(otherID)) stillJoinedPeers.add(pid); - } for (const pid of leftPeers) { if (stillJoinedPeers.has(pid)) continue; if (this.network.isBootstrapOrRelayPeer(pid)) continue; diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index e6f2c0ab..11f9af5c 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -170,6 +170,17 @@ describe('Networks.leaveNetwork — exclusive peer disconnect', () => { expect(net.prunedBootstrap).toEqual([]); }); + it('keeps a left-lishnet bootstrap peer that still subscribes another joined lishnet', async () => { + net.topicPeers.set('net-a', []); + net.topicPeers.set('net-b', ['pBootA']); // pBootA is a live subscriber of net-b + const networks = makeNetworks(net, ['net-a', 'net-b'], { + 'net-a': ['/ip4/192.0.2.5/tcp/9090/p2p/pBootA'], + }); + await leave(networks, 'net-a'); + expect(net.disconnected).toEqual([]); // kept — subscriber of joined net-b + expect(net.prunedBootstrap).toEqual(['pBootA']); // exemption still pruned + }); + it('keeps a left-lishnet bootstrap peer that is still an active circuit relay', async () => { net.topicPeers.set('net-a', []); net.bootstrapOrRelay.add('pRelayNode'); // still relaying another connection From 4b459baff11a33bc7c4d5d6c9bcfaae70bcc9ff7 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 17:54:10 +0200 Subject: [PATCH 38/49] fix(transfer): only suspend enabled downloads on network leave --- backend/src/api/transfer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 6e9e72c2..e4cd42d9 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -146,10 +146,15 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // revive it after verification while no usable lishnet is joined. The // DB flag stays untouched, so an app restart with the lishnet re-joined // resumes the download. + // Only a download that was actually enabled (persisted) may be resumed on + // rejoin. A transient download from the `download` handler lives in + // activeDownloaders but never in downloadEnabledLishs — resuming it would + // wrongly turn it into a persisted enabled download. + const wasEnabled = downloadEnabledLishs.has(lishID); downloadEnabledLishs.delete(lishID); // Remember it as suspended-by-leave so onNetworkJoined can resume it if a // bound lishnet is re-joined in-process (without waiting for a restart). - networkSuspended.add(lishID); + if (wasEnabled) networkSuspended.add(lishID); // Cancel any pending error-recovery timer for this LISH — otherwise // ErrorRecovery, holding the captured downloadWasEnabled=true, could // re-enable the download once the IO condition clears even though the From 41968634252a7fcfc10a3762da7d63a8530f8348 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 20:07:33 +0200 Subject: [PATCH 39/49] fix(network): lift all redial suppression on network rejoin --- backend/src/lishnet/lishnets.ts | 4 ++++ backend/src/protocol/network.ts | 12 ++++++++++++ backend/tests/unit/lishnet/leave-network.test.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index e5f5a706..6c29b182 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -147,6 +147,10 @@ export class Networks { // ordering — the process-level error handlers in app.ts are the safety net. this.network.subscribeTopic(id); this.joinedNetworks.add(id); + // Rejoin is an explicit "I want peers back" — lift any leave-network redial + // suppression so maintenance and discovery may reconnect this network's peers + // (both bootstrap and content peers), not just the re-dialed bootstrap IDs. + this.network.clearRedialSuppression(); const net = this.get(id); if (net && net.bootstrapPeers.length > 0) await this.network.addBootstrapPeers(net.bootstrapPeers, id, 'configured'); diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 4f4fd9cf..849861a8 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -744,6 +744,18 @@ export class Network { return this.redialSuppressed.has(peerID); } + /** + * Lift ALL redial suppression — called on any network (re)join. A rejoin is an + * explicit "I want peers back", and suppression is a flat set with no per-network + * tracking, so we clear it wholesale rather than only the configured-bootstrap IDs + * (which left content peers — mDNS/peer-announce — permanently maintenance-blocked). + * Trade-off: leave A + leave B + rejoin A also unblocks B's peers, but B stays + * unsubscribed and its content is serve-gated, so this is a benign over-clear. + */ + clearRedialSuppression(): void { + this.redialSuppressed.clear(); + } + private async runRedialMaintenance(connectedPeers: any[], allPeers: any[]): Promise { // Dial known peers not currently connected (maintains relay connections to NATed peers) const connectedSet = new Set(connectedPeers.map(p => p.toString())); diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index 11f9af5c..3db86642 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -22,6 +22,8 @@ interface MockNet { isBootstrapOrRelayPeer(pid: string): boolean; disconnectPeer(pid: string): Promise; pruneConfiguredBootstrapPeer(pid: string): void; + clearRedialSuppression(): void; + suppressionCleared: number; } function makeMockNet(): MockNet { @@ -32,6 +34,7 @@ function makeMockNet(): MockNet { disconnected: [], bootstrapOrRelay: new Set(), prunedBootstrap: [], + suppressionCleared: 0, getTopicPeers(id) { return this.topicPeers.get(id) ?? []; }, @@ -52,6 +55,9 @@ function makeMockNet(): MockNet { pruneConfiguredBootstrapPeer(pid) { this.prunedBootstrap.push(pid); }, + clearRedialSuppression() { + this.suppressionCleared++; + }, }; } @@ -219,4 +225,10 @@ describe('Networks.joinNetwork — onNetworkJoined notification', () => { expect(fired).toBe(0); expect(net.subscribed).toEqual([]); }); + + it('lifts redial suppression on join so left peers can reconnect', async () => { + const networks = makeNetworks(net, []); + await join(networks, 'net-a'); + expect(net.suppressionCleared).toBe(1); + }); }); From aa607be40b822792c509cc64adba7c2355ea8207 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 20:09:01 +0200 Subject: [PATCH 40/49] fix(protocol): soften getLishs gate so search reaches unsynced peers --- backend/src/protocol/lish-protocol.ts | 12 ++++--- backend/src/protocol/network.ts | 18 ++++++++++- .../tests/unit/protocol/serve-gate.test.ts | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/backend/src/protocol/lish-protocol.ts b/backend/src/protocol/lish-protocol.ts index e94265c0..871bdc1a 100644 --- a/backend/src/protocol/lish-protocol.ts +++ b/backend/src/protocol/lish-protocol.ts @@ -368,7 +368,7 @@ export function serveGateBlocks(sharesNetworkWith: ((peerID: string) => boolean) return !remotePeerID || !sharesNetworkWith(remotePeerID); } -export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, remotePeerID?: string, connectionType?: ConnectionType, sharesNetworkWith?: (peerID: string) => boolean): Promise { +export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, remotePeerID?: string, connectionType?: ConnectionType, sharesNetworkWith?: (peerID: string) => boolean, canListShares?: (peerID: string) => boolean): Promise { const servedLishIDs = new Set(); const ioErrorCounts = new Map(); // per-LISH consecutive I/O error counter const remotePeer = remotePeerID?.slice(0, 12) ?? 'unknown'; @@ -407,10 +407,12 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, } if (request.type === 'getLishs') { - // Serve the shared-LISH list only to peers we share a joined - // lishnet with. A bare transport connection (e.g. the peer re-dialed - // us right after we left its network) must not reveal what we share. - if (serveGateBlocks(sharesNetworkWith, remotePeerID)) { + // Serve the shared-LISH LISTING under the softer list-gate (canListShares): + // unlike the strict data gate it does not require a synced gossipsub + // SUBSCRIBE, so the unicast search fallback reaches freshly-connected peers. + // Still fail-closed on an unknown peer id and still refuses peers we left. + // Falls back to the strict gate when no list-gate was supplied. + if (serveGateBlocks(canListShares ?? sharesNetworkWith, remotePeerID)) { trace(`[PROTO] getLishs from ${remotePeer} refused: no shared joined lishnet`); const gated: LISHGetLishsResponse = { type: 'getLishs-result', lishs: [] }; sendLengthPrefixed(stream, codecEncode(gated)); diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 849861a8..7accc11b 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -472,7 +472,7 @@ export class Network { } } const connType = remotePeerID ? classifyConnectionFn(remotePeerID, isRelay, this.dcutrPeers) : 'DIRECT'; - await handleLISHProtocol(stream, this.dataServer, remotePeerID, connType, pid => this.sharesJoinedTopicWith(pid)); + await handleLISHProtocol(stream, this.dataServer, remotePeerID, connType, pid => this.sharesJoinedTopicWith(pid), pid => this.canListSharesTo(pid)); } catch (err: any) { trace(`[NET] LISH handler error: ${err?.message ?? err}`); } @@ -1184,6 +1184,22 @@ export class Network { return false; } + /** + * Softer gate for the low-sensitivity shared-LISH LISTING (getLishs) only — + * data requests (getLish/getChunk) stay on the strict {@link sharesJoinedTopicWith} + * fail-closed gate. {@link sharesJoinedTopicWith} relies on gossipsub's subscriber + * view, which lags for a freshly-connected peer whose SUBSCRIBE has not propagated + * yet — the exact window the unicast search fallback targets, so the listing must + * not be withheld there. Serve the listing to any peer over an authenticated stream + * while we are in at least one lishnet, EXCEPT one we deliberately left (still in + * redial suppression) — that preserves the leave-network browse privacy. + */ + canListSharesTo(peerID: string): boolean { + if (this.isRedialSuppressed(peerID)) return false; + if (!this.pubsub) return false; + return this.pubsub.getTopics().some((t: string) => t.startsWith(LISH_TOPIC_PREFIX)); + } + /** * Gracefully disconnect from a single peer and stop libp2p from immediately * re-dialing it. This is the ONLY place that should call `node.hangUp()` so diff --git a/backend/tests/unit/protocol/serve-gate.test.ts b/backend/tests/unit/protocol/serve-gate.test.ts index b8a62957..5157bc04 100644 --- a/backend/tests/unit/protocol/serve-gate.test.ts +++ b/backend/tests/unit/protocol/serve-gate.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { serveGateBlocks } from '../../../src/protocol/lish-protocol.ts'; +import { Network } from '../../../src/protocol/network.ts'; +import { lishTopic } from '../../../src/protocol/constants.ts'; /** * The unicast LISH serve-gate must fail CLOSED: a stream that could not be mapped @@ -26,3 +28,32 @@ describe('serveGateBlocks', () => { expect(serveGateBlocks(() => true, '')).toBe(true); }); }); + +/** + * canListSharesTo is the softer LISTING gate: allow any peer while we hold a joined + * lishnet topic (so unicast search works before SUBSCRIBE syncs), except peers we + * deliberately left (still redial-suppressed) and except when we hold no lishnet. + */ +describe('Network.canListSharesTo', () => { + function bareNetwork(suppressed: string[], topics: string[]) { + const network = Object.create(Network.prototype) as Network; + (network as any).redialSuppressed = new Set(suppressed); + (network as any).pubsub = { getTopics: () => topics }; + return network; + } + + it('allows a fresh peer while we hold a joined lishnet topic (subscribe may lag)', () => { + const net = bareNetwork([], [lishTopic('net-a')]); + expect((net as any).canListSharesTo('peer-a')).toBe(true); + }); + + it('refuses a peer we deliberately left (still suppressed)', () => { + const net = bareNetwork(['peer-left'], [lishTopic('net-a')]); + expect((net as any).canListSharesTo('peer-left')).toBe(false); + }); + + it('refuses when we hold no lishnet topic', () => { + const net = bareNetwork([], []); + expect((net as any).canListSharesTo('peer-a')).toBe(false); + }); +}); From a22c3097310243ac643f10ee73949d8e9d14a306 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 20:10:37 +0200 Subject: [PATCH 41/49] fix(network): forget peerStore entry on leave so disconnect survives restart --- backend/src/protocol/network.ts | 13 +++++++++---- .../tests/unit/protocol/network-disconnect.test.ts | 14 +++++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 7accc11b..e2de215c 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -1208,10 +1208,12 @@ export class Network { * ReconnectQueue would re-dial the peer within seconds and the disconnect * would be pointless. * - * Unlike {@link purgeStalePeer} this does NOT delete the peerStore entry: the - * peer is legitimate (we just no longer have a reason to stay connected after - * leaving its lishnet), so we keep its addresses cached for cheap re-dial if - * the user re-joins. Best-effort: failures are logged at trace, never thrown. + * Also forgets the peerStore entry (via {@link purgeStalePeer}): in-memory redial + * suppression is lost on restart, but the persisted peerStore is not, so a leave + * followed by a restart before rejoin would otherwise let redial maintenance dial + * the left peer straight back. The sole caller (leaveNetwork) only passes peers + * with no remaining reason to stay, and rejoin re-acquires the entry via + * bootstrap/discovery. Best-effort: failures are logged at trace, never thrown. */ async disconnectPeer(peerID: string): Promise { if (!this.node) return; @@ -1246,6 +1248,9 @@ export class Network { // Keep redial maintenance from re-dialing this just-left peer on the next // status tick. Cleared automatically once it reconnects legitimately. this.redialSuppressed.add(peerID); + // Forget the persisted peerStore entry so the disconnect survives a restart — + // suppression is in-memory only, but the peerStore is on disk. + await this.purgeStalePeer(peerID, 'left-network exclusive peer'); } /** Snapshot of all per-network bootstrap statuses. */ diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts index 0c2b7038..b282c30f 100644 --- a/backend/tests/unit/protocol/network-disconnect.test.ts +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -15,19 +15,25 @@ const PEER_ID = '12D3KooWPvH1oQjQZS8TtucG4NsW2PsnW87jwMAiRLKgrNGS17fo'; function makeNetwork() { const merges: Array<{ tags: Record }> = []; const hungUp: string[] = []; + const deleted: string[] = []; const network = Object.create(Network.prototype) as Network; (network as any).redialSuppressed = new Set(); + (network as any).bootstrapPeerIDs = new Set(); (network as any).node = { + getConnections: () => [], peerStore: { async merge(_pid: unknown, patch: { tags: Record }): Promise { merges.push(patch); }, + async delete(pid: { toString(): string }): Promise { + deleted.push(pid.toString()); + }, }, async hangUp(pid: { toString(): string }): Promise { hungUp.push(pid.toString()); }, }; - return { network, merges, hungUp }; + return { network, merges, hungUp, deleted }; } describe('Network.disconnectPeer — keep-alive tag removal', () => { @@ -64,6 +70,12 @@ describe('Network.disconnectPeer — keep-alive tag removal', () => { await network.disconnectPeer(PEER_ID); expect((network as any).redialSuppressed.has(PEER_ID)).toBe(true); }); + + it('forgets the peerStore entry so the disconnect survives a restart', async () => { + const { network, deleted } = makeNetwork(); + await network.disconnectPeer(PEER_ID); + expect(deleted).toEqual([PEER_ID]); + }); }); /** From 25f826f50f1c1e92810124c6e764955bfed9af4a Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 20:11:13 +0200 Subject: [PATCH 42/49] fix(transfer): hide leave-disabled downloads from active transfers --- backend/src/api/transfer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index e4cd42d9..5fe8bbbd 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -444,8 +444,11 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, function getActiveTransfers(): ActiveTransfer[] { const transfers: ActiveTransfer[] = []; const enabled = getEnabledUploads(); - // Active downloads + // Active downloads. A downloader disabled by leaving its last lishnet stays + // in the map (retained for resume on rejoin) but is stopped — skip it so the + // LISH is not reported as still downloading after transfer.download:disabled. for (const [lishID, dl] of activeDownloaders) { + if (dl.isDisabled?.()) continue; transfers.push({ lishID, type: 'downloading', peers: dl.getPeerCount?.() ?? 0, bytesPerSecond: 0 }); } // Active uploads From 871173de02d53f9458b64418564a087bb492d59f Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 20:58:40 +0200 Subject: [PATCH 43/49] refactor(network): track redial suppression per lishnet --- backend/src/lishnet/lishnets.ts | 12 +-- backend/src/protocol/network.ts | 81 ++++++++++++------- .../tests/unit/lishnet/leave-network.test.ts | 16 ++-- .../unit/protocol/network-disconnect.test.ts | 70 +++++++++++----- .../tests/unit/protocol/serve-gate.test.ts | 2 +- 5 files changed, 120 insertions(+), 61 deletions(-) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index 6c29b182..a5e33e77 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -147,10 +147,10 @@ export class Networks { // ordering — the process-level error handlers in app.ts are the safety net. this.network.subscribeTopic(id); this.joinedNetworks.add(id); - // Rejoin is an explicit "I want peers back" — lift any leave-network redial - // suppression so maintenance and discovery may reconnect this network's peers - // (both bootstrap and content peers), not just the re-dialed bootstrap IDs. - this.network.clearRedialSuppression(); + // Rejoin is an explicit "I want peers back" — lift the redial suppression for + // THIS lishnet's left peers (bootstrap and content) so maintenance and discovery + // may reconnect them. Scoped per-network: still-left lishnets stay suppressed. + this.network.clearRedialSuppressionForNetwork(id); const net = this.get(id); if (net && net.bootstrapPeers.length > 0) await this.network.addBootstrapPeers(net.bootstrapPeers, id, 'configured'); @@ -227,7 +227,7 @@ export class Networks { this.network.pruneConfiguredBootstrapPeer(pid); if (stillJoinedPeers.has(pid)) continue; if (this.network.isBootstrapOrRelayPeer(pid)) continue; - await this.network.disconnectPeer(pid); + await this.network.disconnectPeer(pid, id); } // Disconnect peers that belonged exclusively to the lishnet we just left. @@ -240,7 +240,7 @@ export class Networks { for (const pid of leftPeers) { if (stillJoinedPeers.has(pid)) continue; if (this.network.isBootstrapOrRelayPeer(pid)) continue; - await this.network.disconnectPeer(pid); + await this.network.disconnectPeer(pid, id); } const net = this.get(id); diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index e2de215c..f5ccb021 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -202,14 +202,18 @@ export class Network { private readonly redialBackoff = new Map(); /** - * Peers deliberately hung up by {@link disconnectPeer} (leave-network) that - * redial maintenance must NOT proactively re-dial — otherwise a peer we just - * left is re-connected within one status tick (~30s), defeating the leave. - * Cleared the moment the peer is observed connected again by any legitimate - * path (its own re-dial, or mesh reconnection after we re-join). Pruned when - * the peer leaves the peerStore so the set stays bounded. + * Peers deliberately hung up by {@link disconnectPeer} (leave-network), keyed by + * the lishnet they were left with. Redial maintenance / discovery must NOT + * proactively re-dial them — otherwise a peer we just left is re-connected within + * one status tick (~30s), defeating the leave. Per-network so rejoining lishnet A + * lifts only A's peers (not still-left B's — {@link clearRedialSuppressionForNetwork}). + * A peer observed reconnecting by any path is lifted from ALL sets + * ({@link clearRedialSuppressionForPeer}). Bounded by peers of currently-left + * lishnets; drained on rejoin/reconnect and cleared in stop(). NOT pruned against + * the peerStore: leave purges the peer from the store, which would drop the + * suppression and let mDNS rediscovery reconnect within a tick. */ - private readonly redialSuppressed = new Set(); + private readonly redialSuppressedByNet = new Map>(); // Tracked libp2p/pubsub event listeners for clean removal in stop(). // Each entry captures the exact handler reference so removeEventListener can unhook it. @@ -571,6 +575,9 @@ export class Network { }); console.debug(' Tagged as KEEP_ALIVE (bootstrap peer)'); } + // A peer that reconnects (inbound or otherwise) is legitimately back — + // lift any leave-network redial suppression so maintenance resumes for it. + this.clearRedialSuppressionForPeer(peerID); this.schedulePeerCountCheck(); } catch (err: any) { trace(`[NET] peer:connect handler error: ${err?.message ?? err}`); @@ -736,24 +743,37 @@ export class Network { } /** - * Whether a peer was deliberately hung up by leave-network (via disconnectPeer) - * and must NOT be proactively re-dialed by any maintenance path — redial loop, - * zero-connection recovery, or periodic promote — until it reconnects on its own. + * Flat view over the per-network sets: whether a peer was deliberately hung up by + * leave-network (via disconnectPeer) for ANY left lishnet, so no maintenance path + * (redial loop, zero-connection recovery, promote, discovery) re-dials it. */ private isRedialSuppressed(peerID: string): boolean { - return this.redialSuppressed.has(peerID); + for (const set of this.redialSuppressedByNet.values()) if (set.has(peerID)) return true; + return false; + } + + /** Record a peer as left with a specific lishnet so maintenance won't re-dial it. */ + private addRedialSuppression(networkID: string, peerID: string): void { + let set = this.redialSuppressedByNet.get(networkID); + if (!set) { + set = new Set(); + this.redialSuppressedByNet.set(networkID, set); + } + set.add(peerID); } /** - * Lift ALL redial suppression — called on any network (re)join. A rejoin is an - * explicit "I want peers back", and suppression is a flat set with no per-network - * tracking, so we clear it wholesale rather than only the configured-bootstrap IDs - * (which left content peers — mDNS/peer-announce — permanently maintenance-blocked). - * Trade-off: leave A + leave B + rejoin A also unblocks B's peers, but B stays - * unsubscribed and its content is serve-gated, so this is a benign over-clear. + * Lift suppression for one lishnet's peers — called on (re)join of that lishnet. + * Scoped: rejoining A does not unblock still-left B's peers (nor lift the + * canListSharesTo browse-privacy protecting B). */ - clearRedialSuppression(): void { - this.redialSuppressed.clear(); + clearRedialSuppressionForNetwork(networkID: string): void { + this.redialSuppressedByNet.delete(networkID); + } + + /** Lift suppression for one peer across ALL left lishnets — a legitimate reconnect. */ + private clearRedialSuppressionForPeer(peerID: string): void { + for (const set of this.redialSuppressedByNet.values()) set.delete(peerID); } private async runRedialMaintenance(connectedPeers: any[], allPeers: any[]): Promise { @@ -772,7 +792,7 @@ export class Network { const pid = peer.id.toString(); if (connectedSet.has(pid)) { this.redialBackoff.delete(pid); // clear on observed connection - this.redialSuppressed.delete(pid); // legitimately reconnected → resume maintenance + this.clearRedialSuppressionForPeer(pid); // legitimately reconnected → resume maintenance continue; } // Skip peers we deliberately left (leave-network) so maintenance does not @@ -850,10 +870,13 @@ export class Network { if (candidates.length > 0 || skippedBackoff > 0 || skippedNoReachable > 0 || skippedSuppressed > 0) { console.debug(` Re-dial: ${redialSuccess}/${candidates.length} succeeded (${skippedBackoff} skipped by backoff, ${skippedNoReachable} skipped no-reachable-addrs, ${skippedSuppressed} skipped left-peer)`); } - // Prune backoff / suppression entries for peers no longer in peerStore to prevent unbounded growth. + // Prune backoff entries for peers no longer in peerStore to prevent unbounded growth. + // Suppression is NOT pruned this way: leave-network purges the peer from the + // peerStore, so pruning against it would drop the suppression and let mDNS + // rediscovery reconnect the left peer within a tick. Suppression is instead + // bounded by clear-on-rejoin / clear-on-reconnect / stop(). const storeSet = new Set(allPeers.map(p => p.id.toString())); for (const pid of this.redialBackoff.keys()) if (!storeSet.has(pid)) this.redialBackoff.delete(pid); - for (const pid of this.redialSuppressed) if (!storeSet.has(pid)) this.redialSuppressed.delete(pid); } private async runZeroConnectionRecovery(connectedPeers: any[]): Promise { @@ -1015,7 +1038,7 @@ export class Network { // is no longer "left", so lift any redial suppression left by a prior // leaveNetwork, otherwise maintenance would skip it forever if this one // explicit dial fails or the connection drops before the next tick. - this.redialSuppressed.delete(peerID); + this.clearRedialSuppressionForPeer(peerID); } const alreadyKnown = !!peerID && this.bootstrapPeerIDs.has(peerID); if (peerID && !alreadyKnown) { @@ -1214,8 +1237,11 @@ export class Network { * the left peer straight back. The sole caller (leaveNetwork) only passes peers * with no remaining reason to stay, and rejoin re-acquires the entry via * bootstrap/discovery. Best-effort: failures are logged at trace, never thrown. + * + * `networkID` is the lishnet the peer is being left with — the peer is suppressed + * under it so rejoining that lishnet lifts exactly its peers. */ - async disconnectPeer(peerID: string): Promise { + async disconnectPeer(peerID: string, networkID: string): Promise { if (!this.node) return; let pid: PeerID; try { @@ -1246,8 +1272,9 @@ export class Network { trace(`[NET] disconnectPeer: hangUp failed for ${peerID.slice(0, 16)}: ${err?.message ?? err}`); } // Keep redial maintenance from re-dialing this just-left peer on the next - // status tick. Cleared automatically once it reconnects legitimately. - this.redialSuppressed.add(peerID); + // status tick. Keyed by the left lishnet so rejoin lifts exactly its peers; + // cleared automatically once it reconnects legitimately. + this.addRedialSuppression(networkID, peerID); // Forget the persisted peerStore entry so the disconnect survives a restart — // suppression is in-memory only, but the peerStore is on disk. await this.purgeStalePeer(peerID, 'left-network exclusive peer'); @@ -1650,7 +1677,7 @@ export class Network { this._lastPeerCounts.clear(); this._lastScores.clear(); this.redialBackoff.clear(); - this.redialSuppressed.clear(); + this.redialSuppressedByNet.clear(); this.pxIngressLogKeys.clear(); if (this.node) { await this.node.stop(); diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index 3db86642..c349140e 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -20,10 +20,10 @@ interface MockNet { unsubscribeTopic(id: string): void; subscribeTopic(id: string): void; isBootstrapOrRelayPeer(pid: string): boolean; - disconnectPeer(pid: string): Promise; + disconnectPeer(pid: string, networkID: string): Promise; pruneConfiguredBootstrapPeer(pid: string): void; - clearRedialSuppression(): void; - suppressionCleared: number; + clearRedialSuppressionForNetwork(networkID: string): void; + suppressionClearedFor: string[]; } function makeMockNet(): MockNet { @@ -34,7 +34,7 @@ function makeMockNet(): MockNet { disconnected: [], bootstrapOrRelay: new Set(), prunedBootstrap: [], - suppressionCleared: 0, + suppressionClearedFor: [], getTopicPeers(id) { return this.topicPeers.get(id) ?? []; }, @@ -55,8 +55,8 @@ function makeMockNet(): MockNet { pruneConfiguredBootstrapPeer(pid) { this.prunedBootstrap.push(pid); }, - clearRedialSuppression() { - this.suppressionCleared++; + clearRedialSuppressionForNetwork(networkID) { + this.suppressionClearedFor.push(networkID); }, }; } @@ -226,9 +226,9 @@ describe('Networks.joinNetwork — onNetworkJoined notification', () => { expect(net.subscribed).toEqual([]); }); - it('lifts redial suppression on join so left peers can reconnect', async () => { + it('lifts redial suppression for the joined lishnet so its left peers can reconnect', async () => { const networks = makeNetworks(net, []); await join(networks, 'net-a'); - expect(net.suppressionCleared).toBe(1); + expect(net.suppressionClearedFor).toEqual(['net-a']); }); }); diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts index b282c30f..7a312cf5 100644 --- a/backend/tests/unit/protocol/network-disconnect.test.ts +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -11,13 +11,14 @@ import { Network } from '../../../src/protocol/network.ts'; */ const PEER_ID = '12D3KooWPvH1oQjQZS8TtucG4NsW2PsnW87jwMAiRLKgrNGS17fo'; +const NET = 'net-a'; function makeNetwork() { const merges: Array<{ tags: Record }> = []; const hungUp: string[] = []; const deleted: string[] = []; const network = Object.create(Network.prototype) as Network; - (network as any).redialSuppressed = new Set(); + (network as any).redialSuppressedByNet = new Map>(); (network as any).bootstrapPeerIDs = new Set(); (network as any).node = { getConnections: () => [], @@ -33,13 +34,14 @@ function makeNetwork() { hungUp.push(pid.toString()); }, }; - return { network, merges, hungUp, deleted }; + const suppressed = (pid: string): boolean => (network as any).isRedialSuppressed(pid); + return { network, merges, hungUp, deleted, suppressed }; } describe('Network.disconnectPeer — keep-alive tag removal', () => { it('clears both keep-alive-fleet and native KEEP_ALIVE tags before hanging up', async () => { const { network, merges, hungUp } = makeNetwork(); - await network.disconnectPeer(PEER_ID); + await network.disconnectPeer(PEER_ID, NET); expect(merges.length).toBe(1); const tags = merges[0]!.tags; expect(Object.keys(tags)).toContain('keep-alive-fleet'); @@ -54,41 +56,71 @@ describe('Network.disconnectPeer — keep-alive tag removal', () => { (network as any).node.peerStore.merge = async (): Promise => { throw new Error('merge failed'); }; - await network.disconnectPeer(PEER_ID); + await network.disconnectPeer(PEER_ID, NET); expect(hungUp).toEqual([PEER_ID]); }); it('is a no-op for an invalid peer id', async () => { const { network, merges, hungUp } = makeNetwork(); - await network.disconnectPeer('not-a-peer-id'); + await network.disconnectPeer('not-a-peer-id', NET); expect(merges).toEqual([]); expect(hungUp).toEqual([]); }); it('suppresses the hung-up peer from redial maintenance', async () => { - const { network } = makeNetwork(); - await network.disconnectPeer(PEER_ID); - expect((network as any).redialSuppressed.has(PEER_ID)).toBe(true); + const { network, suppressed } = makeNetwork(); + await network.disconnectPeer(PEER_ID, NET); + expect(suppressed(PEER_ID)).toBe(true); }); it('forgets the peerStore entry so the disconnect survives a restart', async () => { const { network, deleted } = makeNetwork(); - await network.disconnectPeer(PEER_ID); + await network.disconnectPeer(PEER_ID, NET); expect(deleted).toEqual([PEER_ID]); }); }); /** - * runRedialMaintenance must not re-dial peers that leave-network just hung up - * (they sit in redialSuppressed), and must drop that suppression the moment the - * peer is observed connected again so normal maintenance resumes. + * Per-network suppression: rejoining one lishnet must lift only ITS left peers, + * a legitimate reconnect lifts a peer from all lishnets. + */ +describe('Network per-network redial suppression', () => { + function bareNetwork() { + const network = Object.create(Network.prototype) as Network; + (network as any).redialSuppressedByNet = new Map>(); + return network; + } + + it('rejoin of one lishnet lifts only its suppressed peers', () => { + const net = bareNetwork(); + (net as any).addRedialSuppression('net-a', 'pA'); + (net as any).addRedialSuppression('net-b', 'pB'); + expect((net as any).isRedialSuppressed('pA')).toBe(true); + expect((net as any).isRedialSuppressed('pB')).toBe(true); + net.clearRedialSuppressionForNetwork('net-a'); + expect((net as any).isRedialSuppressed('pA')).toBe(false); + expect((net as any).isRedialSuppressed('pB')).toBe(true); // still-left net-b unaffected + }); + + it('observed reconnect lifts the peer from every lishnet', () => { + const net = bareNetwork(); + (net as any).addRedialSuppression('net-a', 'pX'); + (net as any).addRedialSuppression('net-b', 'pX'); + (net as any).clearRedialSuppressionForPeer('pX'); + expect((net as any).isRedialSuppressed('pX')).toBe(false); + }); +}); + +/** + * runRedialMaintenance must not re-dial peers that leave-network just hung up, + * and must drop that suppression the moment the peer is observed connected again. */ describe('Network.runRedialMaintenance — leave-peer suppression', () => { function bareNetwork(suppressed: string[]) { const dialed: string[] = []; const network = Object.create(Network.prototype) as Network; (network as any).redialBackoff = new Map(); - (network as any).redialSuppressed = new Set(suppressed); + (network as any).redialSuppressedByNet = new Map([['net-x', new Set(suppressed)]]); (network as any).node = { async dial(id: { toString(): string }): Promise { dialed.push(id.toString()); @@ -105,7 +137,7 @@ describe('Network.runRedialMaintenance — leave-peer suppression', () => { const peer = { id: { toString: () => 'pLeft' }, addresses: [{ multiaddr: multiaddr('/ip4/203.0.113.5/tcp/9090') }] }; await run(network, [], [peer]); expect(dialed).toEqual([]); - expect((network as any).redialSuppressed.has('pLeft')).toBe(true); + expect((network as any).isRedialSuppressed('pLeft')).toBe(true); }); it('clears suppression once the peer is observed connected again', async () => { @@ -113,7 +145,7 @@ describe('Network.runRedialMaintenance — leave-peer suppression', () => { const peer = { id: { toString: () => 'pBack' } }; await run(network, [{ toString: () => 'pBack' }], [peer]); expect(dialed).toEqual([]); - expect((network as any).redialSuppressed.has('pBack')).toBe(false); + expect((network as any).isRedialSuppressed('pBack')).toBe(false); }); }); @@ -126,7 +158,7 @@ describe('Network.runZeroConnectionRecovery — leave-peer suppression', () => { function bareNetwork(suppressed: string[], bootstrapMaStrs: string[]) { const dialed: string[] = []; const network = Object.create(Network.prototype) as Network; - (network as any).redialSuppressed = new Set(suppressed); + (network as any).redialSuppressedByNet = new Map([['net-x', new Set(suppressed)]]); (network as any).bootstrapMultiaddrs = bootstrapMaStrs.map(s => multiaddr(s)); (network as any).recentDisconnects = []; (network as any).bootstrapTracker = { entries: () => [] }; @@ -163,7 +195,7 @@ describe('Network.runZeroConnectionRecovery — leave-peer suppression', () => { describe('Network.addBootstrapPeers — rejoin clears suppression', () => { function bareNetwork(suppressed: string[]) { const network = Object.create(Network.prototype) as Network; - (network as any).redialSuppressed = new Set(suppressed); + (network as any).redialSuppressedByNet = new Map([['net-a', new Set(suppressed)]]); (network as any).configuredBootstrapPeerIDs = new Set(); (network as any).bootstrapPeerIDs = new Set(); (network as any).bootstrapMultiaddrs = []; @@ -180,12 +212,12 @@ describe('Network.addBootstrapPeers — rejoin clears suppression', () => { it('lifts suppression for a re-configured bootstrap peer', async () => { const network = bareNetwork([PEER_ID]); await (network as any).addBootstrapPeers([`/ip4/192.0.2.1/tcp/9090/p2p/${PEER_ID}`], 'net-a', 'configured'); - expect((network as any).redialSuppressed.has(PEER_ID)).toBe(false); + expect((network as any).isRedialSuppressed(PEER_ID)).toBe(false); }); it('does not lift suppression for a discovered (non-configured) re-add', async () => { const network = bareNetwork([PEER_ID]); await (network as any).addBootstrapPeers([`/ip4/192.0.2.1/tcp/9090/p2p/${PEER_ID}`], 'net-a', 'discovered'); - expect((network as any).redialSuppressed.has(PEER_ID)).toBe(true); + expect((network as any).isRedialSuppressed(PEER_ID)).toBe(true); }); }); diff --git a/backend/tests/unit/protocol/serve-gate.test.ts b/backend/tests/unit/protocol/serve-gate.test.ts index 5157bc04..dad19637 100644 --- a/backend/tests/unit/protocol/serve-gate.test.ts +++ b/backend/tests/unit/protocol/serve-gate.test.ts @@ -37,7 +37,7 @@ describe('serveGateBlocks', () => { describe('Network.canListSharesTo', () => { function bareNetwork(suppressed: string[], topics: string[]) { const network = Object.create(Network.prototype) as Network; - (network as any).redialSuppressed = new Set(suppressed); + (network as any).redialSuppressedByNet = new Map([['net-x', new Set(suppressed)]]); (network as any).pubsub = { getTopics: () => topics }; return network; } From d3b88db8854c0dd70b574a58ccf133bf8424c0b8 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 20:59:32 +0200 Subject: [PATCH 44/49] fix(transfer): destroy transient download when its last lishnet leaves --- backend/src/api/transfer.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 5fe8bbbd..aab3de37 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -139,27 +139,33 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, dl.removeNetwork?.(networkID); continue; } - console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); - dl.disable(); - // Drop the runtime enabled flag (no DB persist) so `lishs.list` reports - // the download as stopped and restartDownloadIfEnabled cannot silently - // revive it after verification while no usable lishnet is joined. The - // DB flag stays untouched, so an app restart with the lishnet re-joined - // resumes the download. - // Only a download that was actually enabled (persisted) may be resumed on - // rejoin. A transient download from the `download` handler lives in - // activeDownloaders but never in downloadEnabledLishs — resuming it would - // wrongly turn it into a persisted enabled download. + // Drop the runtime enabled flag (no DB persist) so `lishs.list` reports the + // download as stopped and restartDownloadIfEnabled cannot silently revive it + // while no usable lishnet is joined. The DB flag stays untouched, so an app + // restart with the lishnet re-joined resumes the download. const wasEnabled = downloadEnabledLishs.has(lishID); downloadEnabledLishs.delete(lishID); - // Remember it as suspended-by-leave so onNetworkJoined can resume it if a - // bound lishnet is re-joined in-process (without waiting for a restart). - if (wasEnabled) networkSuspended.add(lishID); // Cancel any pending error-recovery timer for this LISH — otherwise // ErrorRecovery, holding the captured downloadWasEnabled=true, could // re-enable the download once the IO condition clears even though the // user just stopped it by leaving the network. recovery.stop(lishID); + if (wasEnabled) { + // Persisted download — retain the disabled downloader and remember it as + // suspended-by-leave so onNetworkJoined can resume it after rejoin. + console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); + dl.disable(); + networkSuspended.add(lishID); + } else { + // Transient download (from the `download` handler, never enabled/persisted) + // has no resume claim — destroy it and drop it from the map instead of + // leaking a disabled downloader with a dangling download() promise and + // registered network handlers (a fresh start of the same LISH would + // otherwise overwrite the map entry without disposing this one). + console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, dropping transient download`); + dl.destroy().catch(err => console.error(`[Transfer] ${lishID.slice(0, 8)}: destroy on leave failed:`, err?.message ?? err)); + activeDownloaders.delete(lishID); + } // dl.disable() alone emits nothing over WS — tell the FE the download // stopped. broadcast?.('transfer.download:disabled', { lishID }); From 86dc71e99074fc7dd924cc9bfbfe05dc1a076e15 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 21:02:07 +0200 Subject: [PATCH 45/49] fix(transfer): bind suspended-download resume to original lishnets --- backend/src/api/transfer.ts | 41 +++++++++++-------- backend/src/protocol/downloader.ts | 9 ++++ .../downloader-remove-network.test.ts | 10 +++++ 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index aab3de37..b8230a4e 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -115,12 +115,15 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, const activeDownloaders = new Map(); setActiveDownloadersRef(activeDownloaders); - // LISHs whose download was suspended because their last joined lishnet was - // left. Their DB enabled flag stays on (see onNetworkLeft), so they are - // resumed by onNetworkJoined when a bound lishnet is re-joined in-process. - // Cleared when the user explicitly enables/disables the download so a rejoin - // never overrides a deliberate user action. - const networkSuspended = new Set(); + // LISHs whose download was suspended because their last joined lishnet was left, + // mapped to the lishnets they were bound to. Their DB enabled flag stays on (see + // onNetworkLeft), so onNetworkJoined resumes them — but only when a BOUND lishnet + // re-joins, never rebinding to an unrelated one. An empty bound set means "no known + // binding" (startup with no joined lishnet, where the fresh downloader would bind + // to whatever is enabled at resume time) → resume on any join. Cleared when the + // user explicitly enables/disables the download so a rejoin never overrides a + // deliberate user action. + const networkSuspended = new Map>(); // When a lishnet is left, stop any download bound EXCLUSIVELY to it: a // downloader keeps running as long as at least one of its networks is still @@ -155,7 +158,10 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // suspended-by-leave so onNetworkJoined can resume it after rejoin. console.log(`[Transfer] ${lishID.slice(0, 8)}: last joined lishnet left, disabling download`); dl.disable(); - networkSuspended.add(lishID); + // Bind resume to the download's ORIGINAL lishnets (not the current set, + // which removeNetwork may have shrunk) so only a re-join of a lishnet this + // download actually belongs to resumes it. + networkSuspended.set(lishID, new Set(dl.getOriginalNetworkIDs?.() ?? dl.getNetworkIDs?.() ?? [])); } else { // Transient download (from the `download` handler, never enabled/persisted) // has no resume claim — destroy it and drop it from the map instead of @@ -181,15 +187,12 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // Re-attach the rejoined network to still-running multi-network downloaders // that dropped it when it was left (no-op if never bound to it or already active). for (const dl of activeDownloaders.values()) dl.addNetwork?.(networkID); - // Drop the suspension ONLY once the resume actually succeeds — a transient - // failure (busy verifying, still no joined lishnet) must be retried on the next - // join, otherwise the resume is lost forever. A retained (disabled) downloader - // is resumed only when the rejoined network is one it sources from; a suspended - // entry without a downloader (e.g. startup with no joined lishnet) is attempted - // directly (enableDownload rebinds it to the currently joined lishnets). - for (const lishID of [...networkSuspended]) { - const dl = activeDownloaders.get(lishID); - if (dl && !dl.getNetworkIDs?.().includes(networkID)) continue; + // Resume a suspended download only when a lishnet it is BOUND to re-joins (an + // empty bound set = no known binding → resume on any join). Drop the suspension + // ONLY once the resume actually succeeds — a transient failure (busy verifying, + // still no joined lishnet) must be retried on the next join. + for (const [lishID, bound] of [...networkSuspended]) { + if (bound.size > 0 && !bound.has(networkID)) continue; enableDownload({ lishID }) .then(r => { if (r.success) { @@ -360,7 +363,11 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, // rejoin could never resume. Drop only the in-memory active flag and mark // it suspended so onNetworkJoined resumes it once a lishnet is (re-)joined. downloadEnabledLishs.delete(p.lishID); - networkSuspended.add(p.lishID); + // No active downloader and no joined lishnet → no known network binding + // (the fresh downloader would bind to getEnabled(), empty here). Store the + // empty bound set so onNetworkJoined resumes on any join, since the DB has + // no per-download network to restrict to. + networkSuspended.set(p.lishID, new Set(joinedNetworks)); return { success: false }; } const downloadDir = lish.directory ?? join(dataDir, 'downloads', Date.now().toString()); diff --git a/backend/src/protocol/downloader.ts b/backend/src/protocol/downloader.ts index 5ac6df90..8ad8f396 100644 --- a/backend/src/protocol/downloader.ts +++ b/backend/src/protocol/downloader.ts @@ -123,6 +123,15 @@ export class Downloader { return [...this.networkIDs]; } + /** + * The lishnets this download was originally created with, before any + * {@link removeNetwork} shrank the active set. Used to decide which lishnet + * re-joins may resume a suspended download. Defensive copy. + */ + getOriginalNetworkIDs(): string[] { + return [...this.originalNetworkIDs]; + } + /** * Stop sourcing this download from a lishnet the node just left. Removes the * network from the set so subsequent WANT broadcasts and topic-peer probes no diff --git a/backend/tests/unit/protocol/downloader-remove-network.test.ts b/backend/tests/unit/protocol/downloader-remove-network.test.ts index 06cfd7d1..df9553bd 100644 --- a/backend/tests/unit/protocol/downloader-remove-network.test.ts +++ b/backend/tests/unit/protocol/downloader-remove-network.test.ts @@ -56,3 +56,13 @@ describe('Downloader.addNetwork', () => { expect(dl.getNetworkIDs()).toEqual(['net-a', 'net-b']); }); }); + +describe('Downloader.getOriginalNetworkIDs', () => { + it('stays the full original set even after removeNetwork shrinks the active set', () => { + const dl = makeDownloader(['net-a', 'net-b']); + dl.removeNetwork('net-a'); + expect(dl.getNetworkIDs()).toEqual(['net-b']); + // Resume-on-rejoin binds to this, so leaving+rejoining net-a can still resume. + expect(dl.getOriginalNetworkIDs()).toEqual(['net-a', 'net-b']); + }); +}); From 304a41765415748f517251775363fb8b00c7a3f2 Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 21:39:43 +0200 Subject: [PATCH 46/49] fix(network): gate suppression clear and direct-set on shared topic --- backend/src/protocol/network.ts | 36 ++++++++++++++++--- .../unit/protocol/network-disconnect.test.ts | 21 +++++++++-- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index ef1dfd2f..ea6ea8f0 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -478,7 +478,14 @@ export class Network { } } const connType = remotePeerID ? classifyConnectionFn(remotePeerID, isRelay, this.dcutrPeers) : 'DIRECT'; - await handleLISHProtocol(stream, this.dataServer, remotePeerID, connType, pid => this.sharesJoinedTopicWith(pid), pid => this.canListSharesTo(pid)); + await handleLISHProtocol( + stream, + this.dataServer, + remotePeerID, + connType, + pid => this.sharesJoinedTopicWith(pid), + pid => this.canListSharesTo(pid) + ); } catch (err: any) { trace(`[NET] LISH handler error: ${err?.message ?? err}`); } @@ -577,9 +584,12 @@ export class Network { }); console.debug(' Tagged as KEEP_ALIVE (bootstrap peer)'); } - // A peer that reconnects (inbound or otherwise) is legitimately back — - // lift any leave-network redial suppression so maintenance resumes for it. - this.clearRedialSuppressionForPeer(peerID); + // A mere reconnect does NOT lift leave-network suppression: a peer we left can + // dial us back (its own keep-alive/mDNS) without rejoining a shared topic, and + // clearing here would remove the only marker canListSharesTo uses to refuse it. + // Suppression lifts only on an explicit rejoin (clearRedialSuppressionForNetwork) + // or once the peer is verifiably back on a joined topic (genuine mesh reconnect). + if (this.sharesJoinedTopicWith(peerID)) this.clearRedialSuppressionForPeer(peerID); this.schedulePeerCountCheck(); } catch (err: any) { trace(`[NET] peer:connect handler error: ${err?.message ?? err}`); @@ -794,7 +804,7 @@ export class Network { const pid = peer.id.toString(); if (connectedSet.has(pid)) { this.redialBackoff.delete(pid); // clear on observed connection - this.clearRedialSuppressionForPeer(pid); // legitimately reconnected → resume maintenance + if (this.sharesJoinedTopicWith(pid)) this.clearRedialSuppressionForPeer(pid); // back on a shared topic → resume continue; } // Skip peers we deliberately left (leave-network) so maintenance does not @@ -970,6 +980,10 @@ export class Network { for (const peer of allPeers) { const pid = peer.id.toString(); if (pid === myID) continue; + // A left-network peer that lingers/reappears in the peerStore must not be + // added to the direct set either — its fast reconnect cadence would undo the + // leave-network disconnect (same guard as the bootstrap promotion above). + if (this.isRedialSuppressed(pid)) continue; if (!gossipsub.direct.has(pid)) { gossipsub.direct.add(pid); added++; @@ -1187,6 +1201,11 @@ export class Network { } } + /** Recently-seen subscribers of a lishnet's topic (TTL union, not just the live snapshot). */ + getRecentTopicMembers(networkID: string): string[] { + return this.peerAnnounce.getRecentMembers(lishTopic(networkID)); + } + /** * True if we currently share at least one joined lishnet topic with the * given peer — i.e. some lish topic WE are subscribed to lists the peer @@ -1223,6 +1242,13 @@ export class Network { canListSharesTo(peerID: string): boolean { if (this.isRedialSuppressed(peerID)) return false; if (!this.pubsub) return false; + // Infrastructure peers (active relay / bootstrap) are kept connected across a + // leave without being redial-suppressed, so the softer gate alone would let a + // relay of a network we just left browse our shares. Require such peers to + // currently share a joined topic. Ordinary content peers still get the soft + // gate — that is the freshly-connected-before-SUBSCRIBE window the search + // fallback depends on. + if (this.isBootstrapOrRelayPeer(peerID) && !this.sharesJoinedTopicWith(peerID)) return false; return this.pubsub.getTopics().some((t: string) => t.startsWith(LISH_TOPIC_PREFIX)); } diff --git a/backend/tests/unit/protocol/network-disconnect.test.ts b/backend/tests/unit/protocol/network-disconnect.test.ts index 7a312cf5..a5bf81a5 100644 --- a/backend/tests/unit/protocol/network-disconnect.test.ts +++ b/backend/tests/unit/protocol/network-disconnect.test.ts @@ -116,11 +116,17 @@ describe('Network per-network redial suppression', () => { * and must drop that suppression the moment the peer is observed connected again. */ describe('Network.runRedialMaintenance — leave-peer suppression', () => { - function bareNetwork(suppressed: string[]) { + function bareNetwork(suppressed: string[], sharedTopicPeers: string[] = []) { const dialed: string[] = []; const network = Object.create(Network.prototype) as Network; (network as any).redialBackoff = new Map(); (network as any).redialSuppressedByNet = new Map([['net-x', new Set(suppressed)]]); + // A reconnected peer's suppression is lifted only if it currently shares a joined + // topic — model that via a pubsub whose subscribers list the "back on topic" peers. + (network as any).pubsub = { + getTopics: () => ['lish/net-x'], + getSubscribers: () => sharedTopicPeers.map(p => ({ toString: () => p })), + }; (network as any).node = { async dial(id: { toString(): string }): Promise { dialed.push(id.toString()); @@ -140,13 +146,22 @@ describe('Network.runRedialMaintenance — leave-peer suppression', () => { expect((network as any).isRedialSuppressed('pLeft')).toBe(true); }); - it('clears suppression once the peer is observed connected again', async () => { - const { network, dialed } = bareNetwork(['pBack']); + it('clears suppression when a reconnected peer is back on a shared topic', async () => { + const { network, dialed } = bareNetwork(['pBack'], ['pBack']); const peer = { id: { toString: () => 'pBack' } }; await run(network, [{ toString: () => 'pBack' }], [peer]); expect(dialed).toEqual([]); expect((network as any).isRedialSuppressed('pBack')).toBe(false); }); + + it('keeps suppression for a reconnected peer NOT back on a shared topic', async () => { + // A left peer dialing us back (keep-alive/mDNS) without rejoining a shared topic + // must stay suppressed — otherwise canListSharesTo would serve it our catalog. + const { network } = bareNetwork(['pBack'], []); + const peer = { id: { toString: () => 'pBack' } }; + await run(network, [{ toString: () => 'pBack' }], [peer]); + expect((network as any).isRedialSuppressed('pBack')).toBe(true); + }); }); /** From 965170381e1835e77522afc0ed64b3505d606e5d Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 21:39:44 +0200 Subject: [PATCH 47/49] fix(network): suppress recently-seen offline content peers on leave --- backend/src/lishnet/lishnets.ts | 7 ++++++- backend/src/protocol/peer-announce.ts | 15 +++++++++++++++ backend/tests/unit/lishnet/leave-network.test.ts | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index a5e33e77..2062f70b 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -199,7 +199,12 @@ export class Networks { // Snapshot the topic subscribers BEFORE unsubscribing — unsubscribeTopic // tears the topic out of pubsub, after which getTopicPeers(id) returns []. - const leftPeers = this.network.getTopicPeers(id); + // Union with recently-seen members (TTL) so a content peer that is momentarily + // disconnected at leave time — but still holds a peerStore entry — is also + // suppressed; a live-subscriber-only snapshot would miss it and maintenance + // could redial it back after we left. + const leftPeers = new Set(this.network.getTopicPeers(id)); + for (const pid of this.network.getRecentTopicMembers(id)) leftPeers.add(pid); this.network.unsubscribeTopic(id); this.joinedNetworks.delete(id); diff --git a/backend/src/protocol/peer-announce.ts b/backend/src/protocol/peer-announce.ts index 1d98348d..686dc49c 100644 --- a/backend/src/protocol/peer-announce.ts +++ b/backend/src/protocol/peer-announce.ts @@ -98,6 +98,21 @@ export class PeerAnnounceManager { this.deps = deps; } + /** + * Recently-seen subscribers of a topic (union of getSubscribers over the last + * {@link PEER_ANNOUNCE_MEMBER_TTL_MS}), so a same-network peer that is momentarily + * disconnected at query time is still reported. Used by leave-network to suppress + * offline content peers that a live subscriber snapshot would miss. + */ + getRecentMembers(topic: string): string[] { + const members = this.topicMembers.get(topic); + if (!members) return []; + const now = Date.now(); + const out: string[] = []; + for (const [pid, seen] of members) if (now - seen <= PEER_ANNOUNCE_MEMBER_TTL_MS) out.push(pid); + return out; + } + /** Start the periodic emitter. Safe to call only once per start/stop cycle. */ start(): void { this.stopped = false; diff --git a/backend/tests/unit/lishnet/leave-network.test.ts b/backend/tests/unit/lishnet/leave-network.test.ts index c349140e..705616ec 100644 --- a/backend/tests/unit/lishnet/leave-network.test.ts +++ b/backend/tests/unit/lishnet/leave-network.test.ts @@ -11,12 +11,14 @@ import { Networks } from '../../../src/lishnet/lishnets.ts'; interface MockNet { topicPeers: Map; + recentMembers: Map; unsubscribed: string[]; subscribed: string[]; disconnected: string[]; bootstrapOrRelay: Set; prunedBootstrap: string[]; getTopicPeers(id: string): string[]; + getRecentTopicMembers(id: string): string[]; unsubscribeTopic(id: string): void; subscribeTopic(id: string): void; isBootstrapOrRelayPeer(pid: string): boolean; @@ -29,6 +31,7 @@ interface MockNet { function makeMockNet(): MockNet { return { topicPeers: new Map(), + recentMembers: new Map(), unsubscribed: [], subscribed: [], disconnected: [], @@ -38,6 +41,9 @@ function makeMockNet(): MockNet { getTopicPeers(id) { return this.topicPeers.get(id) ?? []; }, + getRecentTopicMembers(id) { + return this.recentMembers.get(id) ?? []; + }, unsubscribeTopic(id) { this.unsubscribed.push(id); // Mirror real pubsub: after unsubscribe the topic reports no peers. @@ -90,6 +96,16 @@ describe('Networks.leaveNetwork — exclusive peer disconnect', () => { expect(net.disconnected).toEqual(['p-only-a']); }); + it('disconnects a recently-seen content peer offline at leave time', async () => { + // Not a live subscriber right now, but seen within TTL and still in the peerStore — + // must be suppressed too, or maintenance would redial it after the leave. + net.topicPeers.set('net-a', ['p-live']); + net.recentMembers.set('net-a', ['p-live', 'p-offline']); + const networks = makeNetworks(net, ['net-a']); + await leave(networks, 'net-a'); + expect(net.disconnected.sort()).toEqual(['p-live', 'p-offline']); + }); + it('keeps bootstrap/relay peers even when exclusive to the left lishnet', async () => { net.topicPeers.set('net-a', ['p-bootstrap', 'p-plain']); net.bootstrapOrRelay.add('p-bootstrap'); From 302ed5b6fe22d1947a63c590b7d6680fdf0fc4eb Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 21:39:44 +0200 Subject: [PATCH 48/49] fix(network): require infrastructure peers to share a topic to list --- .../tests/unit/protocol/serve-gate.test.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/tests/unit/protocol/serve-gate.test.ts b/backend/tests/unit/protocol/serve-gate.test.ts index dad19637..580f0474 100644 --- a/backend/tests/unit/protocol/serve-gate.test.ts +++ b/backend/tests/unit/protocol/serve-gate.test.ts @@ -35,10 +35,14 @@ describe('serveGateBlocks', () => { * deliberately left (still redial-suppressed) and except when we hold no lishnet. */ describe('Network.canListSharesTo', () => { - function bareNetwork(suppressed: string[], topics: string[]) { + function bareNetwork(suppressed: string[], topics: string[], infra: string[] = [], subscribers: string[] = []) { const network = Object.create(Network.prototype) as Network; (network as any).redialSuppressedByNet = new Map([['net-x', new Set(suppressed)]]); - (network as any).pubsub = { getTopics: () => topics }; + (network as any).pubsub = { + getTopics: () => topics, + getSubscribers: () => subscribers.map(p => ({ toString: () => p })), + }; + (network as any).isBootstrapOrRelayPeer = (pid: string) => infra.includes(pid); return network; } @@ -56,4 +60,16 @@ describe('Network.canListSharesTo', () => { const net = bareNetwork([], []); expect((net as any).canListSharesTo('peer-a')).toBe(false); }); + + it('refuses a kept infrastructure peer that no longer shares a joined topic', () => { + // A relay/bootstrap of a left network is kept connected but must not browse our + // shares unless it currently shares a joined topic. + const net = bareNetwork([], [lishTopic('net-a')], ['relay-x'], []); + expect((net as any).canListSharesTo('relay-x')).toBe(false); + }); + + it('allows an infrastructure peer that still shares a joined topic', () => { + const net = bareNetwork([], [lishTopic('net-a')], ['relay-x'], ['relay-x']); + expect((net as any).canListSharesTo('relay-x')).toBe(true); + }); }); From ddda6e2b3a97438bdc54978eed1c4190cb449efc Mon Sep 17 00:00:00 2001 From: LuRy Date: Wed, 22 Jul 2026 21:39:44 +0200 Subject: [PATCH 49/49] fix(transfer): keep leave-suspended download stopped until bound rejoin --- backend/src/api/transfer.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/api/transfer.ts b/backend/src/api/transfer.ts index 29a189f2..7e7fed42 100644 --- a/backend/src/api/transfer.ts +++ b/backend/src/api/transfer.ts @@ -292,6 +292,16 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, persistDownloadEnabled?.(p.lishID, true); const dl = activeDownloaders.get(p.lishID); if (dl) { + // A download suspended by leaving its last lishnet is retained (disabled) for + // resume on rejoin. Enabling it before a bound lishnet is re-joined would make + // it broadcast WANTs / probe on a topic we already left — keep it suspended. + const boundIDs = dl.getOriginalNetworkIDs?.() ?? dl.getNetworkIDs?.() ?? []; + if (networkSuspended.has(p.lishID) && boundIDs.length > 0 && !boundIDs.some((id: string) => networks.isJoined(id))) { + // Drop the runtime enabled flag we just set; the DB flag (persisted above) + // stays true so a later rejoin of a bound lishnet resumes the download. + downloadEnabledLishs.delete(p.lishID); + return { success: false }; + } // If downloader is in error state, destroy it and create a fresh one if (dl.getError()) { console.debug(`[Transfer] ${p.lishID.slice(0, 8)}: destroying error-state downloader, will create fresh`);