Skip to content

Commit 43ddb9b

Browse files
committed
fix(updater/uninstaller) + refactor: huge-file partial-class splits (2026.4.27.0-0001)
Updater fixes (the "version.txt never updates" + SmartScreen denials): - updater.exe self-relaunches from %TEMP% when started inside the install dir. Directory.Move can't rename a folder containing the running process's image, so the swap was failing silently and the console flashed shut without Pause(). Symptom was version.txt staying stale and the "Update available" banner reappearing on next launch. - Pause() on every error path so silent failures stop being silent. - Catastrophic-failure screen when both swap and rollback fail: prints the backup-dir path, points at GitHub releases, opens the page in the browser, tells the user to manually re-download. - app.manifest with asInvoker on updater.exe + uninstall.exe to opt out of Windows' filename-based "looks like an installer" auto-UAC prompt — the cause of the original ERROR_CANCELLED. Uninstaller now removes the `127.0.0.1 localhost.youtube.com` hosts entry. Spawns self with `--remove-hosts-entry` and Verb=runas (same pattern HostsManager uses for setup) so the rest of the uninstall flow stays non-elevated. UAC declined → leave the line in place with a log line. UI: Force-update fallback. When LaunchSidecarAndExit returns ERROR_CANCELLED or similar, the new Sidecar Launch Failure modal offers a "retry with admin elevation" button. The retry runs Unblock-File on the sidecar exe (strips SmartScreen's Mark-of-the-Web) then re-launches with Verb=runas for an explicit admin prompt. Refactor — partial-class splits for the largest files in the repo: ResolutionEngine.cs: 2298 → 1004 (5 sibling partial files) .YtDlpProcess.cs — yt-dlp invocation, output parsing, probe headers, bgutil plugin args, bot-detection regex .Tiers.cs — AttemptTier1/2/3, ResolveTierN, RunTier1Attempt, RunBrowserExtract, ResolveStreamlink .ColdRace.cs — strategy ordering, host budget, race builder .UrlClassification.cs — ExtractHost / IsVrchatTrustedHost / ApplyRelayWrap .PlaybackFeedback.cs — relay-abort + AVPro-fail demote loop, recent- resolutions ring Program.cs (UI): 869 → 516 Program.IpcHandlers.cs — HandleWebMessage switch + SendToUi/Send* helpers + LaunchSidecarAndExit appStore.ts: 737 → 602 appStore.types.ts — interfaces + constants (re-exported from appStore.ts so consumer imports keep working) All splits are mechanical — partial classes compile to identical IL, types re-export through the original file. No behavior change. dotnet build clean (0 warnings), 157/157 tests pass, npm run build clean.
1 parent 54e173d commit 43ddb9b

17 files changed

Lines changed: 2527 additions & 1857 deletions

src/WKVRCProxy.Core/Services/ResolutionEngine.ColdRace.cs

Lines changed: 292 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Collections.Generic;
4+
using System.Diagnostics;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Net.Http;
8+
using System.Runtime.Versioning;
9+
using System.Text;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
using WKVRCProxy.Core.Diagnostics;
13+
using WKVRCProxy.Core.IPC;
14+
using WKVRCProxy.Core.Logging;
15+
using WKVRCProxy.Core.Models;
16+
17+
namespace WKVRCProxy.Core.Services;
18+
// Partial — playback-feedback wiring. The cascade is generous about what counts as "success":
19+
// any tier that returns a URL wins. This partial closes the feedback loop by listening for the
20+
// real signals (RelayServer's OnClientAbortedEarly when Unity drops a connection mid-stream,
21+
// VrcLogMonitor's OnAvProLoadFailure when AVPro logs "Loading failed") and demoting the strategy
22+
// that produced the bad URL. The recent-resolutions ring keys a returned URL back to the
23+
// strategy + memKey + history-row that produced it so the demote can target the right entry.
24+
[SupportedOSPlatform("windows")]
25+
public partial class ResolutionEngine
26+
{
27+
// --- moved from ResolutionEngine.cs (lines 106-117) ---
28+
// Short-term record of what each recent resolution handed back. Keyed by BOTH the outgoing
29+
// resolved URL (post-wrap) AND the original user URL — VRChat's AVPro "Opening" log line can
30+
// reference either depending on whether VRChat's own yt-dlp hook intercepted. When
31+
// VrcLogMonitor fires OnAvProLoadFailure with an URL, we look it up here to find the strategy
32+
// that produced it, then demote that strategy with PlaybackFailed (one-strike demote).
33+
//
34+
// HistoryEntryRef lets us flip the matching history row's PlaybackVerified flag on the
35+
// feedback signal — without it, the UI's Success column would still lie about dead URLs.
36+
private record RecentResolution(string StrategyName, string MemKey, string OriginalUrl, string ResolvedUrl, string? UpstreamUrl, DateTime CreatedAt, string CorrelationId, HistoryEntry? HistoryEntryRef);
37+
private readonly ConcurrentDictionary<string, RecentResolution> _recentByUrl = new();
38+
private static readonly TimeSpan RecentResolutionTtl = TimeSpan.FromSeconds(60);
39+
private const int RecentResolutionCap = 64;
40+
41+
// --- moved from ResolutionEngine.cs (lines 127-130) ---
42+
// How long to wait before promoting a history entry from "pending" to "verified". AVPro
43+
// typically logs "Loading failed" within 1-3 seconds of Opening; 8s is comfortable headroom
44+
// without making the UI feel stuck.
45+
private static readonly TimeSpan PlaybackVerifyDelay = TimeSpan.FromSeconds(8);
46+
47+
// --- moved from ResolutionEngine.cs (lines 167-186) ---
48+
// Subscribe to RelayServer's OnClientAbortedEarly so Unity playback failures (which don't
49+
// emit AVPro error lines) also feed the playback-feedback demotion loop. Called once at
50+
// startup after both services are constructed. Keeps RelayServer unaware of the engine.
51+
public void AttachRelayAbortDetector(RelayServer relayServer)
52+
{
53+
if (relayServer == null) return;
54+
relayServer.OnClientAbortedEarly += HandleRelayClientAbort;
55+
}
56+
57+
// Rolling-window tracker of "player aborted mid-stream before playback could have started"
58+
// events, keyed by the UPSTREAM target URL (not the relay-wrapped URL the player fetched).
59+
// When the same target URL is aborted `RelayAbortThreshold` times inside the window, treat
60+
// it as a Unity PlaybackFailed — demote the strategy that produced it, evict the cache, and
61+
// fire the same StrategyDemoted event the AVPro path uses. Mirrors VRChat's retry cadence:
62+
// Unity reopens a failing URL every 5-8s, so 3 aborts inside 30s is a reliable signal with
63+
// zero false positives on normal playback (normal playback never aborts short).
64+
private readonly ConcurrentDictionary<string, Queue<DateTime>> _relayAbortLog = new();
65+
private readonly object _relayAbortLogLock = new();
66+
private static readonly TimeSpan RelayAbortWindow = TimeSpan.FromSeconds(30);
67+
private const int RelayAbortThreshold = 3;
68+
69+
// --- moved from ResolutionEngine.cs (lines 188-219) ---
70+
private void HandleRelayClientAbort(string targetUrl, long bytesAtAbort)
71+
{
72+
if (string.IsNullOrEmpty(targetUrl)) return;
73+
var now = DateTime.UtcNow;
74+
var cutoff = now - RelayAbortWindow;
75+
Queue<DateTime> queue;
76+
int abortCountInWindow;
77+
lock (_relayAbortLogLock)
78+
{
79+
queue = _relayAbortLog.GetOrAdd(targetUrl, _ => new Queue<DateTime>());
80+
while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue();
81+
queue.Enqueue(now);
82+
abortCountInWindow = queue.Count;
83+
}
84+
85+
_logger.Debug("[Playback] Relay abort for " + (targetUrl.Length > 80 ? targetUrl.Substring(0, 80) + "..." : targetUrl) +
86+
" (bytes=" + bytesAtAbort + ", count in " + RelayAbortWindow.TotalSeconds + "s window=" + abortCountInWindow + "/" + RelayAbortThreshold + ")");
87+
88+
if (abortCountInWindow < RelayAbortThreshold) return;
89+
90+
// Threshold hit — the player has repeatedly rejected this URL's format. Reuse the AVPro
91+
// failure handler, which already knows how to: match the URL to a recent resolution,
92+
// demote the strategy with PlaybackFailed, evict resolve cache, publish the event, and
93+
// flip the history entry's PlaybackVerified flag.
94+
lock (_relayAbortLogLock)
95+
{
96+
_relayAbortLog.TryRemove(targetUrl, out _);
97+
}
98+
_logger.Warning("[Playback] [Relay] Unity/AVPro aborted " + abortCountInWindow + "× within " + RelayAbortWindow.TotalSeconds + "s on " +
99+
(targetUrl.Length > 80 ? targetUrl.Substring(0, 80) + "..." : targetUrl) + " — treating as PlaybackFailed.");
100+
HandleAvProLoadFailure(targetUrl, now);
101+
}
102+
103+
// --- moved from ResolutionEngine.cs (lines 221-268) ---
104+
private void HandleAvProLoadFailure(string failedUrl, DateTime observedAt)
105+
{
106+
if (string.IsNullOrWhiteSpace(failedUrl)) return;
107+
PruneRecentResolutions();
108+
if (!_recentByUrl.TryGetValue(failedUrl, out var recent))
109+
{
110+
_logger.Debug("[Playback] AVPro Loading failed for URL not in recent-resolutions ring (" +
111+
(failedUrl.Length > 80 ? failedUrl.Substring(0, 80) + "..." : failedUrl) + "). Ignoring — likely a URL WKVRCProxy did not resolve.");
112+
return;
113+
}
114+
if (observedAt - recent.CreatedAt > RecentResolutionTtl)
115+
{
116+
_logger.Debug("[Playback] AVPro failure for resolved URL older than TTL — not demoting.");
117+
return;
118+
}
119+
_logger.Warning("[Playback] [" + recent.CorrelationId + "] AVPro rejected resolved URL from '" + recent.StrategyName + "' on " + recent.MemKey + ". Demoting (PlaybackFailed) — next request will re-cascade.");
120+
RecordStrategyFailure(recent.MemKey, recent.StrategyName, StrategyFailureKind.PlaybackFailed);
121+
_eventBus?.PublishStrategyDemoted(recent.StrategyName, recent.MemKey, "AVPro rejected URL", recent.CorrelationId);
122+
123+
// Flag the matching history row so the UI's Success column reflects actual playback, not
124+
// just "resolution returned a URL". If the scheduled verifier hasn't run yet, this wins
125+
// over it because it re-reads the flag under a null check.
126+
if (recent.HistoryEntryRef != null && recent.HistoryEntryRef.PlaybackVerified != false)
127+
{
128+
recent.HistoryEntryRef.PlaybackVerified = false;
129+
try { _settings.Save(); }
130+
catch (Exception ex) { _logger.Debug("[Playback] Failed to persist playback-failed flag: " + ex.Message); }
131+
}
132+
133+
// Evict any resolve-cache entry that would replay this dead URL on the duration/thumbnail
134+
// probes that follow an initial play-attempt. Without this the cache would serve the same
135+
// URL for up to 90s and AVPro would keep failing.
136+
foreach (var cacheKey in _resolveCache.Keys.ToList())
137+
{
138+
if (_resolveCache.TryGetValue(cacheKey, out var entry)
139+
&& (entry.Result.Url == recent.ResolvedUrl || cacheKey.EndsWith("|" + recent.OriginalUrl)))
140+
{
141+
_resolveCache.TryRemove(cacheKey, out _);
142+
}
143+
}
144+
// And clear the recent-resolutions entry so a second "Loading failed" for the same URL
145+
// doesn't double-demote.
146+
_recentByUrl.TryRemove(failedUrl, out _);
147+
if (recent.ResolvedUrl != failedUrl) _recentByUrl.TryRemove(recent.ResolvedUrl, out _);
148+
if (recent.OriginalUrl != failedUrl) _recentByUrl.TryRemove(recent.OriginalUrl, out _);
149+
if (!string.IsNullOrEmpty(recent.UpstreamUrl) && recent.UpstreamUrl != failedUrl)
150+
_recentByUrl.TryRemove(recent.UpstreamUrl!, out _);
151+
}
152+
153+
// --- moved from ResolutionEngine.cs (lines 270-302) ---
154+
private void RecordRecentResolution(string originalUrl, string resolvedUrl, string strategyName, string memKey, string correlationId, HistoryEntry? historyEntry = null, string? upstreamUrl = null)
155+
{
156+
if (string.IsNullOrEmpty(strategyName) || string.IsNullOrEmpty(memKey)) return;
157+
var rec = new RecentResolution(strategyName, memKey, originalUrl, resolvedUrl, upstreamUrl, DateTime.UtcNow, correlationId, historyEntry);
158+
_recentByUrl[originalUrl] = rec;
159+
if (!string.Equals(resolvedUrl, originalUrl, StringComparison.Ordinal))
160+
_recentByUrl[resolvedUrl] = rec;
161+
// Relay-abort detector reports the *upstream* URL (decoded `target` param), not the
162+
// wrapped /play?target=… URL the player sees. Without this third key, threshold-hit
163+
// aborts on relay-wrapped strategies (tier2 cloud, etc.) miss the ring lookup and
164+
// never demote — the resolve cache then keeps serving the same dead URL.
165+
if (!string.IsNullOrEmpty(upstreamUrl)
166+
&& !string.Equals(upstreamUrl, originalUrl, StringComparison.Ordinal)
167+
&& !string.Equals(upstreamUrl, resolvedUrl, StringComparison.Ordinal))
168+
_recentByUrl[upstreamUrl] = rec;
169+
PruneRecentResolutions();
170+
171+
// Schedule the optimistic "verified" promotion. If no AVPro failure arrives within the
172+
// delay, we promote to true — a.k.a. "no news is good news". A failure observed in the
173+
// meantime sets PlaybackVerified=false and this task finds it already non-null and bails.
174+
if (historyEntry != null && historyEntry.PlaybackVerified == null)
175+
{
176+
_ = Task.Run(async () =>
177+
{
178+
await Task.Delay(PlaybackVerifyDelay);
179+
if (historyEntry.PlaybackVerified == null)
180+
{
181+
historyEntry.PlaybackVerified = true;
182+
try { _settings.Save(); } catch { /* best-effort persistence */ }
183+
}
184+
});
185+
}
186+
}
187+
188+
// --- moved from ResolutionEngine.cs (lines 304-320) ---
189+
private void PruneRecentResolutions()
190+
{
191+
var now = DateTime.UtcNow;
192+
if (_recentByUrl.Count <= RecentResolutionCap)
193+
{
194+
foreach (var kv in _recentByUrl)
195+
if (now - kv.Value.CreatedAt > RecentResolutionTtl) _recentByUrl.TryRemove(kv.Key, out _);
196+
return;
197+
}
198+
// Hard cap hit: drop oldest until under cap, plus any past TTL.
199+
var ordered = _recentByUrl.OrderBy(kv => kv.Value.CreatedAt).ToList();
200+
foreach (var kv in ordered)
201+
{
202+
if (_recentByUrl.Count <= RecentResolutionCap && now - kv.Value.CreatedAt <= RecentResolutionTtl) break;
203+
_recentByUrl.TryRemove(kv.Key, out _);
204+
}
205+
}
206+
207+
}

0 commit comments

Comments
 (0)