-
Notifications
You must be signed in to change notification settings - Fork 3
fix(network): disconnect exclusive peers and disable downloads on leave-network #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
25508c6
d043cb6
a1d3690
898d494
2228b8c
fc1362c
3161670
a93b3a9
a3b9c39
a83208c
5744095
0010fb5
4a78862
bba1741
7d413eb
caa7823
96c81a4
c1fb566
7df3ba5
b5f61c5
9992b23
6c5027b
deb2971
0cac337
f151caf
97bbabf
7dba87b
c9b788d
5511386
709e03d
6d6189e
20dedfb
76ab8f3
709c3d3
fe15912
b8e90dc
c4a1caa
4b459ba
4196863
aa607be
a22c309
25f826f
871173d
d3b88db
86dc71e
d951c2f
304a417
9651703
302ed5b
ddda6e2
0e88111
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -115,6 +115,95 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, | |
| const activeDownloaders = new Map<string, Downloader>(); | ||
| setActiveDownloadersRef(activeDownloaders); | ||
|
|
||
| // 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<string, Set<string>>(); | ||
|
|
||
| // 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))) { | ||
| // 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; | ||
| } | ||
| // 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the last joined lishnet for an enabled download is left, this removes the LISH from the in-memory enabled set while intentionally leaving the DB flag enabled. In the same process, re-enabling that lishnet only runs Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 7dba87b — LISHs disabled because their last joined lishnet was left are tracked as network-suspended; a new onNetworkJoined hook re-enables them when a bound lishnet is re-joined in-process. An explicit user enable/disable clears the suspension so a rejoin never overrides a deliberate user action. |
||
| // 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in the current code: this path retains a disabled downloader in Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in ddda6e2 — enableDownload now refuses to resume a leave-suspended download (in networkSuspended) unless one of its ORIGINAL bound lishnets is currently joined; it drops the runtime flag while keeping the DB intent, so a manual enable after leaving the last lishnet keeps it suspended instead of broadcasting on the left topic. |
||
| // 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 | ||
| // 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 }); | ||
| } | ||
| }; | ||
|
|
||
| // 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) => { | ||
| // 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); | ||
| // 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) { | ||
| 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)); | ||
| } | ||
| }; | ||
|
|
||
| // Error recovery: auto-retry when IO conditions clear | ||
| const recovery = new ErrorRecovery({ | ||
| attemptRecover: async (lishID, downloadWasEnabled, uploadWasEnabled): Promise<boolean> => { | ||
|
|
@@ -181,6 +270,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); | ||
|
|
@@ -202,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`); | ||
|
|
@@ -268,8 +368,16 @@ 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); | ||
| // 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()); | ||
|
|
@@ -361,7 +469,11 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, | |
| const enabled = getEnabledUploads(); | ||
| // Active downloads — report the allocation phase distinctly so the UI can show | ||
| // "allocating" after a reconnect instead of falling back to idle (no peers yet). | ||
| // 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; | ||
| const type = dl.isAllocating?.() ? 'allocating' : 'downloading'; | ||
| transfers.push({ lishID, type, peers: dl.getPeerCount?.() ?? 0, bytesPerSecond: 0 }); | ||
| } | ||
|
|
@@ -491,6 +603,7 @@ export function initTransferHandlers(networks: Networks, dataServer: DataServer, | |
| } | ||
| activeDownloaders.clear(); | ||
| downloadEnabledLishs.clear(); | ||
| networkSuspended.clear(); | ||
| clearAllUploads(); | ||
| recovery.stopAll(); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,12 @@ 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; | ||
| // 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; | ||
|
|
@@ -50,6 +56,27 @@ 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; | ||
| } | ||
|
|
||
| /** | ||
| * 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'); | ||
| } | ||
|
|
@@ -120,24 +147,113 @@ 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 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'); | ||
|
|
||
| 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); | ||
| } | ||
|
|
||
| /** | ||
| * Leave a lishnet (unsubscribe from its topic). | ||
| */ | ||
| /** Peer IDs (the /p2p/<id> component) of a list of bootstrap multiaddr strings. */ | ||
| private static bootstrapPeerIDsOf(bootstrapPeers: string[]): string[] { | ||
| const ids: string[] = []; | ||
| for (const addr of bootstrapPeers) { | ||
| // Relayed multiaddrs (.../p2p/<relay>/p2p-circuit/p2p/<target>) 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; | ||
| } | ||
|
|
||
| /** Configured-bootstrap peer IDs of a single network. */ | ||
| private configuredBootstrapPeerIDsOf(networkID: string): Set<string> { | ||
| return new Set(Networks.bootstrapPeerIDsOf(this.get(networkID)?.bootstrapPeers ?? [])); | ||
| } | ||
|
|
||
| /** Configured-bootstrap peer IDs of every joined network except `exceptID`. */ | ||
| private configuredBootstrapPeerIDsElsewhere(exceptID: string): Set<string> { | ||
| const out = new Set<string>(); | ||
| 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<void> { | ||
| if (!this.joinedNetworks.has(id)) return; | ||
|
|
||
| // Snapshot the topic subscribers BEFORE unsubscribing — unsubscribeTopic | ||
| // tears the topic out of pubsub, after which getTopicPeers(id) returns []. | ||
| // 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<string>(this.network.getTopicPeers(id)); | ||
| for (const pid of this.network.getRecentTopicMembers(id)) leftPeers.add(pid); | ||
|
|
||
| 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<string>(); | ||
| 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. 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, 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). | ||
| for (const pid of leftPeers) { | ||
| if (stillJoinedPeers.has(pid)) continue; | ||
| if (this.network.isBootstrapOrRelayPeer(pid)) continue; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When an exclusive left-lishnet peer is an active relay/bootstrap that we intentionally keep connected, this Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 302ed5b — canListSharesTo now requires a kept infrastructure peer (active relay/bootstrap, isBootstrapOrRelayPeer) to currently share a joined topic; a relay of a network we just left can no longer browse our catalog. Ordinary content peers still get the soft gate for the SUBSCRIBE-lag search-fallback window. |
||
| await this.network.disconnectPeer(pid, id); | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -308,6 +424,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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an enabled download originally spans networks A and B, leaving A while B remains joined takes this branch and permanently filters A out of the downloader's
networkIDs. SinceonNetworkJoinedonly handles downloads innetworkSuspended, surviving multi-network downloads are never updated when A rejoins, so they continue broadcasting/probing only on B until a restart or manual toggle; if B has no useful sources, the download stays stalled even though the source network was rejoined.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 20dedfb — the downloader keeps an immutable originalNetworkIDs snapshot and gains addNetwork(); onNetworkJoined re-attaches a rejoined lishnet to every running download that originally spanned it (no-op for foreign or already-active networks), so surviving multi-network downloads resume sourcing from the rejoined topic.