fix: Change Account signed out BEFORE the browser opened
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google chooser -> back to a signed-out app, then "cannot sign-in because the callback url was not released". Two independent defects: 1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`. SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the working account's token was destroyed before the browser even opened. It is now just `await SignInAsync()`: success overwrites the token via the sessionChanged hook, cancel/failure keeps the current session. The explicit sign-out was never needed. 2. Abandoning the browser gives the app no signal, so the OAuth attempt held localhost:8765 for its full 5-minute timeout and a retry could not bind. A new AuthenticateAsync now cancels and awaits the abandoned attempt first. That takeover is serialised by a SemaphoreSlim: the first version cleared the in-flight marker before awaiting, so a caller arriving in that window saw a free port and raced to bind it -- reproducing the very failure it was written to prevent. My own test caught that; reasoning had "proved" it fine. Also adds an `openBrowser` ctor seam so tests never shell out to a real browser, and corrects the ai.md line that documented "Logout / Change Account -> SignOutYouTubeAsync" as intended behaviour. Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering fix has no automated test because _youtubeAuth is hardcoded in MainViewModel and ChangeAccountAsync is private. The reported cross-instance effect is NOT this cause and stays open -- no code path can sign out a second instance (per-instance TokenStore, local delete, no server-side revoke).
This commit is contained in:
@@ -83,4 +83,38 @@ public class YouTubeAuthServiceTests
|
||||
|
||||
Assert.Null(service.CurrentChannel);
|
||||
}
|
||||
|
||||
/// <summary>Creator report 2026-09-27: clicking "Change Account", cancelling in the
|
||||
/// Google account chooser, then clicking again failed with "cannot sign-in because
|
||||
/// the callback url was not released". Abandoning the browser gives the app no
|
||||
/// signal, so the first attempt sat on the loopback callback port for its full
|
||||
/// 5-minute timeout and the second attempt could not bind.
|
||||
/// <para>Requires nothing else to be listening on port 8765 — close a running
|
||||
/// LlamaCasty that happens to be mid-sign-in, or this test sees a genuine
|
||||
/// "address already in use" from the host.</para></summary>
|
||||
[Fact]
|
||||
public async Task RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort()
|
||||
{
|
||||
var service = new YouTubeAuthService(
|
||||
"test-id", "test-secret",
|
||||
new HttpClient(new FakeHandler("{}", "{}")),
|
||||
openBrowser: _ => { }); // never shell out to a real browser from a test
|
||||
|
||||
var first = service.AuthenticateAsync();
|
||||
|
||||
// The creator gives up on the chooser and clicks "Change Account" again.
|
||||
var second = service.AuthenticateAsync();
|
||||
|
||||
// The abandoned attempt must be released promptly rather than left holding the
|
||||
// port for its whole 5-minute timeout. If it weren't, this await would time out.
|
||||
Assert.Null(await first.WaitAsync(TimeSpan.FromSeconds(15)));
|
||||
|
||||
// Taking over again proves `second` really did bind the port: had it not,
|
||||
// awaiting it would throw HttpListenerException ("address already in use").
|
||||
_ = service.AuthenticateAsync();
|
||||
Assert.Null(await second.WaitAsync(TimeSpan.FromSeconds(15)));
|
||||
|
||||
// The last attempt is deliberately left pending — nothing can cancel it and the
|
||||
// test host exits before its 5-minute timer fires.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user