vc=41: loop round 3/5 — round-2 misses caught + duplicate-entry zip guard
Three Opus round-6 audits found important regressions on the vc=39/40
fixes plus a fresh hostile-zip attack surface.
HIGH
R6-1 SearchViewModel.submit fence-by-query swallowed valid
results. `onQueryChange` mutates _ui.value.query without
cancelling inFlight (for reactive cache filtering as the
user types). The vc=40 string-equality fence treated a
still-valid result as stale just because the user kept
typing after submitting. Re-fenced by Job identity via
ensureActive() — only a fresh submit (which calls
inFlight?.cancel()) invalidates us, mere typing doesn't.
R6-2 SettingsImport.run wrapped `runInner` (suspend) in plain
runCatching, swallowing CancellationException and
surfacing it as a Result.failure that produced a misleading
"import failed" banner on a user-back abort. Migrated to
runCatchingCancellable — exactly what round 5 added the
util for; this call site was missed.
R6-3 VideoDetail/ChannelViewModel.load early-return on bad URL
didn't cancel inFlight. An in-flight prior load could
resolve past the suspension point, see its fence pass
(loadedUrl unchanged because we didn't update it on the
rejected call), and clobber the "Unsupported URL" error
banner the user is looking at. Now: inFlight?.cancel() +
inFlight = null before the early return.
R6-4 Rust ensure_initialized mutex contention pathology. The
vc=40 mutex-first ordering correctly serialized init but
blocked N concurrent tokio workers for the full duration
of a slow ReqwestDownloader::new() (e.g. ~7s on a 6s DNS
timeout). On a 4-core phone that froze the app for 7s.
New: backoff check FIRST (lock-free), then try_lock — if
someone else is initializing, return immediately. Caller
gets one DownloaderMissing they can retry past, instead
of multi-second UI lockup.
MED
R6-5 Hostile zip with duplicate `newpipe.db` entries could
masquerade a benign first DB past any pre-validation and
ship the second malicious one (ZipInputStream walks in
order; second write overwrites). Now: reject the archive
when either `newpipe.db` or `preferences.json` appears
twice.
R6-6 importPlaylists outer + inner queries had no LIMIT — a
crafted DB with 10M playlist rows or 10M items could walk
unbounded cursors into memory even under the 256 MB DB
size cap. LIMIT 256 / LIMIT 5000 now match the discipline
on the other import paths.
R6-7 SponsorBlock pickActiveSegment had a `posSec < endSec -
0.05` exclusion that combined with the 150ms polling
cadence missed short (<200ms) filler/reminder segments
entirely. Dropped — the rate-limit work made it
unnecessary.
R6-8 SettingsImport.importSettings `applied++` was outside the
`want != have` branch — inflated the import-summary count
to "12 settings applied" when only 2 changed.
Deferred:
- PlaylistsStore URL canonicalization (still deferred — needs
shared YT-id-extract util, not blocking)
- SQLite header magic validation (low-value defense-in-depth)
- SP write reorder serialization (bigger refactor)
- updateAvatar coalesced persists (12 parallel = wasted CPU
but not visible)
- Proguard rules staging (paired with R8 enable, both deferred)
- DASH/HLS maxResolution cap via TrackSelectionParameters
(real bug but needs careful Media3 wiring)
This commit is contained in:
parent
da48109a4d
commit
ecc54aaf38
7 changed files with 88 additions and 40 deletions
|
|
@ -43,32 +43,33 @@ fn now_ms() -> u64 {
|
|||
}
|
||||
|
||||
pub fn ensure_initialized() {
|
||||
// Fast path: already initialized. Just an Acquire load.
|
||||
// Fast path: already initialized. Single Acquire load.
|
||||
if INITIALIZED.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// Acquire the lock FIRST. The mutex is the in-progress queue —
|
||||
// concurrent callers wait here while one thread does init.
|
||||
// Round-5 audit HIGH-1: the prior shape stamped LAST_ATTEMPT_MS
|
||||
// at the start of an attempt then let concurrent callers
|
||||
// short-circuit-out via the backoff check; they'd then call into
|
||||
// the extractor before init completed → DownloaderMissing.
|
||||
let _guard = match INIT_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
// Re-check under the lock — another thread may have just succeeded.
|
||||
if INITIALIZED.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// Now the backoff check — but ONLY skips when a *prior* failed
|
||||
// attempt is still in cooldown. If LAST_ATTEMPT_MS is zero, no
|
||||
// attempt has been made yet; the lock fall-through proceeds.
|
||||
// Backoff check BEFORE the lock — a recent failure shouldn't
|
||||
// make N concurrent callers queue on a mutex they'll all skip
|
||||
// out of anyway.
|
||||
let last = LAST_ATTEMPT_MS.load(Ordering::Acquire);
|
||||
let now = now_ms();
|
||||
if last != 0 && now.saturating_sub(last) < RETRY_BACKOFF_MS {
|
||||
return;
|
||||
}
|
||||
// try_lock — if another thread is already mid-init, return
|
||||
// immediately rather than block. The caller will get
|
||||
// DownloaderMissing once from the extractor and recover on
|
||||
// the next user action; the alternative (blocking N tokio
|
||||
// workers for the full duration of a slow init) freezes the
|
||||
// UI. Round-6 audit HIGH-2 was the regression on round-5's
|
||||
// mutex-first ordering.
|
||||
let _guard = match INIT_LOCK.try_lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Re-check under the lock — another thread may have just succeeded.
|
||||
if INITIALIZED.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
match ReqwestDownloader::new() {
|
||||
Ok(dl) => {
|
||||
NewPipe::init_full(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue