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
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:
parent
91f17f5ab2
commit
8fe6217b5a
@ -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(() =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user