fix(02-08): cap content.browse-peer fan-out — root cause of Cloud first-visit hang
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m29s

Instrumented the exact trigger the fresh-mount guard (previous commit)
didn't fully eliminate: on a fresh session, loadPeerFiles() fired one
content.browse-peer RPC per connected peer with zero concurrency cap
and a 30s per-call timeout. Confirmed on archi-dev-box: 13 of 14
concurrent browse-peer calls never settled at all (dead/unreachable
peers with no server-side timeout on that path) — that many
simultaneously open, indefinitely-pending same-origin requests starved
Chromium's connection pool, silently breaking every other same-origin
fetch for the rest of the session, including the lazy route chunk any
later folder/tab navigation needs. This — not the router or the click
handler — was the actual cause of "no folders open on click" after a
first Cloud visit.

Fix, mirroring PeerFiles.vue's existing PREVIEW_CONCURRENCY pattern for
the identical class of problem (content.preview-peer fan-out):
- Cap the browse-peer fan-out at 3 concurrent requests
  (BROWSE_PEER_CONCURRENCY, a queue+worker pool in loadPeerFiles()).
- Shorten each call's timeout from 30s to 10s (BROWSE_PEER_TIMEOUT_MS) —
  bounds how long any one dead peer can hold a connection.
- Wire an AbortController (aborted onUnmounted) through rpc-client's
  signal option for clean teardown.
- A timed-out/failed peer already resolved silently through
  resources.ts's own error-state path (no throw, no toast) — confirmed
  unchanged; the muted "N peers unreachable" line is the only surface.

No visual/behavioral change to the working case (D-01 rule) — peers
that answer still render exactly as before, just no longer share the
page with a dozen never-ending requests.

Full suite green (95 files / 774 tests), type-check and build clean,
keepAliveTabs.test.ts structural DOM assertions untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 23:33:32 -04:00
parent 91f17f5ab2
commit 8fe6217b5a

View File

@ -402,7 +402,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onActivated, onMounted } from 'vue'
import { computed, ref, watch, onActivated, onMounted, onUnmounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
@ -769,20 +769,61 @@ interface PeerBrowse {
const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}`
// 02-08 fix: content.browse-peer has no server-side timeout for an
// unreachable/dead .onion peer confirmed on real hardware that 13 of 14
// concurrent calls to this method (one per connected peer, previously fired
// with zero concurrency cap from loadPeerFiles below) never settled at all
// within 15s+. That many simultaneously-open, indefinitely-pending same-
// origin requests starved Chromium's connection pool and silently broke
// every OTHER same-origin fetch for the rest of the session including the
// lazy route chunk any later tab/folder navigation needs (the actual cause
// behind "no folders open on click" after a first Cloud visit). A single
// dead peer's own RPC still costs at most this timeout once (maxRetries: 1
// already prevented the ×3 retry-storm version of this bug); the fix here
// is bounding how many of these can be simultaneously in flight and how
// long any one of them is allowed to hold a connection open, mirroring the
// PREVIEW_CONCURRENCY pattern PeerFiles.vue already uses for the identical
// class of problem (content.preview-peer fan-out).
const BROWSE_PEER_TIMEOUT_MS = 10_000
const browsePeerAborter = new AbortController()
onUnmounted(() => browsePeerAborter.abort())
function browsePeer(peer: PeerNode): Promise<void> {
return resources.refresh<PeerBrowse>(peerBrowseKey(peer.onion), async () => {
const t0 = Date.now()
const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer',
params: { onion: peer.onion },
timeout: 30000,
timeout: BROWSE_PEER_TIMEOUT_MS,
// One slow/unreachable peer must cost its timeout ONCE, not ×3
// the retry loop is why one dead peer meant a 90s spinner.
maxRetries: 1,
dedup: true,
signal: browsePeerAborter.signal,
})
return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
})
// A timed-out/failed peer resolves through resources.ts's own catch path
// (loadState -> 'error', no throw here) it never blocks or delays any
// other peer's slot, and the UI already surfaces this silently (the
// muted "N peers unreachable showing what answered" line below, no
// toast) rather than as an error the user must dismiss.
}
/** Concurrency-capped content.browse-peer fan-out bounds how many of
* these can be simultaneously in flight (see BROWSE_PEER_TIMEOUT_MS note
* above). Matches PeerFiles.vue's PREVIEW_CONCURRENCY convention. */
const BROWSE_PEER_CONCURRENCY = 3
async function runBrowsePeerPool(targets: PeerNode[]): Promise<void> {
let cursor = 0
async function worker(): Promise<void> {
while (cursor < targets.length) {
const peer = targets[cursor++]!
await browsePeer(peer).catch(() => {}) // errors already land in the resource's own error state
}
}
const workerCount = Math.min(BROWSE_PEER_CONCURRENCY, targets.length)
await Promise.all(Array.from({ length: workerCount }, () => worker()))
}
function peerBrowseEntry(onion: string) {
@ -835,7 +876,9 @@ const peerFilesLoading = computed(() =>
peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length,
)
/** Fan out content.browse-peer; each peer renders as it resolves. */
/** Fan out content.browse-peer, capped at BROWSE_PEER_CONCURRENCY at a time;
* each peer renders as it resolves regardless of the cap (the aggregation
* above is reactive per-entry, not gated on the whole batch finishing). */
async function loadPeerFiles(force = false) {
if (peerNodes.value.length === 0) await loadPeers()
const targets = peerNodes.value.filter(p => {
@ -844,8 +887,7 @@ async function loadPeerFiles(force = false) {
const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000
return e.loadState === 'idle' || e.loadState === 'error' ? true : stale
})
// Fire-and-collect: the computed aggregation updates per resolution.
await Promise.allSettled(targets.map(p => browsePeer(p)))
await runBrowsePeerPool(targets)
}
const filteredPeerFiles = computed(() =>