From e6ab6994b59c7fbbccb661dda1460d673fa53a80 Mon Sep 17 00:00:00 2001 From: Delun Gong Date: Tue, 1 Sep 2026 17:04:08 +0800 Subject: [PATCH] fix(windows): publish account usage in background --- .../LocalSyncPublisherTests.cs | 138 ++++++++++++++++++ .../TokenTrackerWin.Tests.csproj | 1 + TokenTrackerWin/LocalSyncPublisher.cs | 77 ++++++++++ TokenTrackerWin/ServerManager.cs | 74 +++++++++- src/lib/local-api.js | 3 + test/local-api-background.test.js | 30 +++- test/windows-background-sync-args.test.js | 41 ++++-- test/windows-background-sync-source.test.js | 37 ++++- 8 files changed, 374 insertions(+), 27 deletions(-) create mode 100644 TokenTrackerWin.Tests/LocalSyncPublisherTests.cs create mode 100644 TokenTrackerWin/LocalSyncPublisher.cs diff --git a/TokenTrackerWin.Tests/LocalSyncPublisherTests.cs b/TokenTrackerWin.Tests/LocalSyncPublisherTests.cs new file mode 100644 index 000000000..ad9be5883 --- /dev/null +++ b/TokenTrackerWin.Tests/LocalSyncPublisherTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace TokenTrackerWin; + +public sealed class LocalSyncPublisherTests +{ + [Fact] + public async Task AuthenticatesThenPublishesExactBackgroundPayload() + { + const string baseUrl = "http://127.0.0.1:17680"; + var requests = new List(); + using var client = new HttpClient(new ScriptedHandler(async (request, cancellationToken) => + { + var body = request.Content is null + ? "" + : await request.Content.ReadAsStringAsync(cancellationToken); + var localAuth = request.Headers.TryGetValues( + "x-tokentracker-local-auth", out var values) + ? values.SingleOrDefault() + : null; + requests.Add(new CapturedRequest(request.Method, request.RequestUri!, localAuth, body)); + return JsonResponse(requests.Count == 1 + ? "{\"token\":\"local-secret\"}" + : "{\"ok\":true}"); + })); + + var publisher = new LocalSyncPublisher(client, baseUrl); + await publisher.PublishAsync(); + + Assert.Equal(2, requests.Count); + Assert.Equal(HttpMethod.Get, requests[0].Method); + Assert.Equal(baseUrl + "/api/local-auth", requests[0].Uri.ToString()); + Assert.Null(requests[0].LocalAuth); + Assert.Equal(HttpMethod.Post, requests[1].Method); + Assert.Equal(baseUrl + "/functions/tokentracker-local-sync", requests[1].Uri.ToString()); + Assert.Equal("local-secret", requests[1].LocalAuth); + Assert.Equal( + "{\"auto\":true,\"background\":true,\"allLocalSources\":true,\"publishAccount\":true,\"nativeOnlyWsl\":true}", + requests[1].Body); + + using var document = JsonDocument.Parse(requests[1].Body); + var payload = document.RootElement; + Assert.Equal(5, payload.EnumerateObject().Count()); + Assert.True(payload.GetProperty("auto").GetBoolean()); + Assert.True(payload.GetProperty("background").GetBoolean()); + Assert.True(payload.GetProperty("allLocalSources").GetBoolean()); + Assert.True(payload.GetProperty("publishAccount").GetBoolean()); + Assert.True(payload.GetProperty("nativeOnlyWsl").GetBoolean()); + } + + [Fact] + public async Task RejectsNonSuccessAuthResponseWithoutEchoingResponseBody() + { + var calls = 0; + using var client = new HttpClient(new ScriptedHandler((_, _) => + { + calls++; + return Task.FromResult(JsonResponse( + "{\"error\":\"local-secret\"}", + HttpStatusCode.ServiceUnavailable)); + })); + + var publisher = new LocalSyncPublisher(client, "http://127.0.0.1:17680"); + var error = await Assert.ThrowsAsync( + () => publisher.PublishAsync()); + + Assert.Equal(1, calls); + Assert.DoesNotContain("local-secret", error.Message); + } + + [Fact] + public async Task RejectsMissingAuthTokenWithoutEchoingResponseBody() + { + using var client = new HttpClient(new ScriptedHandler((_, _) => + Task.FromResult(JsonResponse("{\"error\":\"local-secret\"}")))); + + var publisher = new LocalSyncPublisher(client, "http://127.0.0.1:17680"); + var error = await Assert.ThrowsAsync( + () => publisher.PublishAsync()); + + Assert.Equal("Local auth response did not include a token.", error.Message); + Assert.DoesNotContain("local-secret", error.Message); + } + + [Fact] + public async Task PropagatesCancellationDuringPost() + { + var postStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + using var client = new HttpClient(new ScriptedHandler(async (request, cancellationToken) => + { + calls++; + if (calls == 1) return JsonResponse("{\"token\":\"local-secret\"}"); + + postStarted.SetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + })); + + var publisher = new LocalSyncPublisher(client, "http://127.0.0.1:17680"); + using var cancellation = new CancellationTokenSource(); + var publishTask = publisher.PublishAsync(cancellation.Token); + await postStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => publishTask); + Assert.Equal(2, calls); + } + + private static HttpResponseMessage JsonResponse( + string body, + HttpStatusCode status = HttpStatusCode.OK) + => new(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private sealed record CapturedRequest( + HttpMethod Method, + Uri Uri, + string? LocalAuth, + string Body); + + private sealed class ScriptedHandler( + Func> script) + : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => script(request, cancellationToken); + } +} diff --git a/TokenTrackerWin.Tests/TokenTrackerWin.Tests.csproj b/TokenTrackerWin.Tests/TokenTrackerWin.Tests.csproj index 2d4a3c9ba..b48f0f156 100644 --- a/TokenTrackerWin.Tests/TokenTrackerWin.Tests.csproj +++ b/TokenTrackerWin.Tests/TokenTrackerWin.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/TokenTrackerWin/LocalSyncPublisher.cs b/TokenTrackerWin/LocalSyncPublisher.cs new file mode 100644 index 000000000..7b09a2b9e --- /dev/null +++ b/TokenTrackerWin/LocalSyncPublisher.cs @@ -0,0 +1,77 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace TokenTrackerWin; + +/// +/// Performs the authenticated loopback exchange used by native background sync. +/// Keeping this protocol separate from the process lifecycle makes the request +/// ordering, flags, and cancellation behavior independently testable. +/// +internal sealed class LocalSyncPublisher +{ + private const string LocalAuthHeader = "x-tokentracker-local-auth"; + private const string BackgroundSyncBody = + "{\"auto\":true,\"background\":true,\"allLocalSources\":true,\"publishAccount\":true,\"nativeOnlyWsl\":true}"; + + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + public LocalSyncPublisher(HttpClient httpClient, string baseUrl) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ArgumentException("A loopback base URL is required.", nameof(baseUrl)); + _baseUrl = baseUrl.TrimEnd('/'); + } + + public async Task PublishAsync(CancellationToken cancellationToken = default) + { + using var authResponse = await _httpClient.GetAsync( + _baseUrl + "/api/local-auth", + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + authResponse.EnsureSuccessStatusCode(); + + var authPayload = await authResponse.Content.ReadAsStringAsync(cancellationToken); + var localAuthToken = ReadLocalAuthToken(authPayload); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + _baseUrl + "/functions/tokentracker-local-sync"); + request.Headers.TryAddWithoutValidation(LocalAuthHeader, localAuthToken); + request.Content = new StringContent(BackgroundSyncBody, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + response.EnsureSuccessStatusCode(); + // The local API writes its response after the sync child exits. Consume + // it so PublishAsync does not complete before that child is finished. + await response.Content.ReadAsStringAsync(cancellationToken); + } + + private static string ReadLocalAuthToken(string payload) + { + try + { + using var document = JsonDocument.Parse(payload); + if (document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty("token", out var tokenElement) && + tokenElement.ValueKind == JsonValueKind.String) + { + var token = tokenElement.GetString(); + if (!string.IsNullOrWhiteSpace(token)) return token; + } + } + catch (JsonException) + { + // Convert malformed auth responses to the same safe protocol error + // without echoing a response body that could contain credentials. + } + + throw new InvalidOperationException("Local auth response did not include a token."); + } +} diff --git a/TokenTrackerWin/ServerManager.cs b/TokenTrackerWin/ServerManager.cs index 99086f9b7..7e8a77425 100644 --- a/TokenTrackerWin/ServerManager.cs +++ b/TokenTrackerWin/ServerManager.cs @@ -39,6 +39,8 @@ public enum ServerStatus { Idle, Starting, Running, Failed } private Process? _serverProcess; private Process? _syncProcess; + private CancellationTokenSource? _backgroundSyncCts; + private bool _syncInFlight; private readonly object _syncLock = new(); private readonly JobObject _job = new(); private CancellationTokenSource? _healthCts; @@ -49,6 +51,14 @@ public enum ServerStatus { Idle, Starting, Running, Failed } private static readonly HttpClient Http = new(new HttpClientHandler { UseProxy = false }) { Timeout = TimeSpan.FromSeconds(3) }; + // The local API owns its configured network timeouts (which may be + // unbounded) and its 120s sync-child timeout. Do not impose a finite + // native timeout that could release the single-flight slot while the + // server is still publishing; shutdown cancels this request and kills the + // server/child process instead. + private static readonly HttpClient LocalSyncHttp = + new(new HttpClientHandler { UseProxy = false }) { Timeout = Timeout.InfiniteTimeSpan }; + /// Raised on the thread-pool when the running state flips. UI must marshal to the UI thread. public event Action? StatusChanged; @@ -90,31 +100,43 @@ public async Task EnsureServerRunningAsync() /// Run a one-shot `tracker sync` against the resolved runtime. public void TriggerSync() { - StartSync(auto: false); + StartDirectSync(); } /// Run a quiet, non-overlapping background sync for live tray totals. public void TriggerBackgroundSync() { - StartSync(auto: true); + CancellationTokenSource cts; + lock (_syncLock) + { + if (_syncInFlight) return; + + cts = new CancellationTokenSource(); + _backgroundSyncCts = cts; + _syncInFlight = true; + SyncStarted?.Invoke(); + } + + // Keep the timer/UI thread free while the local API runs the sync child. + _ = RunBackgroundSyncAsync(cts); } - private bool StartSync(bool auto) + private bool StartDirectSync() { var runtime = FindEmbeddedServer() ?? FindDevServer() ?? FindRepoDevServer(); if (runtime is null) return false; lock (_syncLock) { - if (_syncProcess is { HasExited: false }) return false; + if (_syncInFlight) return false; - var args = auto - ? new[] { "sync", "--auto", "--background" } - : new[] { "sync" }; + var args = new[] { "sync" }; var proc = StartTrackerProcess( - runtime.Value.NodePath, runtime.Value.EntryPath, auto, args); + runtime.Value.NodePath, runtime.Value.EntryPath, + false, args); if (proc is null) return false; + _syncInFlight = true; _syncProcess = proc; proc.EnableRaisingEvents = true; proc.Exited += (_, _) => @@ -124,6 +146,7 @@ private bool StartSync(bool auto) lock (_syncLock) { if (ReferenceEquals(_syncProcess, proc)) _syncProcess = null; + _syncInFlight = false; } try { proc.Dispose(); } catch { } SyncCompleted?.Invoke(); @@ -133,6 +156,33 @@ private bool StartSync(bool auto) } } + private async Task RunBackgroundSyncAsync(CancellationTokenSource cts) + { + try + { + await new LocalSyncPublisher(LocalSyncHttp, BaseUrl).PublishAsync(cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + Log("background sync cancelled"); + } + catch (Exception ex) + { + // Never include the local-auth response/header in diagnostics. + Log($"background sync failed ({ex.GetType().Name})"); + } + finally + { + lock (_syncLock) + { + if (ReferenceEquals(_backgroundSyncCts, cts)) _backgroundSyncCts = null; + _syncInFlight = false; + } + cts.Dispose(); + SyncCompleted?.Invoke(); + } + } + public void StopServer() { _healthCts?.Cancel(); @@ -148,6 +198,14 @@ public void StopServer() _syncProcess = null; } + // Cancel an in-flight authenticated background request without waiting + // on the UI thread. The server process is killed below as part of the + // same shutdown path. + lock (_syncLock) + { + _backgroundSyncCts?.Cancel(); + } + if (_serverProcess is { HasExited: false } p) { try { p.Kill(entireProcessTree: true); } diff --git a/src/lib/local-api.js b/src/lib/local-api.js index 3f7b9838d..50899987f 100644 --- a/src/lib/local-api.js +++ b/src/lib/local-api.js @@ -1936,6 +1936,9 @@ function createLocalApiHandler({ queuePath }) { const publishAccount = background && body.publishAccount === true && getCloudSyncPref(); const allLocalSources = background && body.allLocalSources === true; + if (background && body.nativeOnlyWsl === true) { + extraEnv.TOKENTRACKER_WSL_MODE = "native-only"; + } if (typeof body.deviceToken === "string" && body.deviceToken.trim()) { extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim(); } diff --git a/test/local-api-background.test.js b/test/local-api-background.test.js index de2731067..109154b9c 100644 --- a/test/local-api-background.test.js +++ b/test/local-api-background.test.js @@ -156,7 +156,7 @@ function installDeviceTokenFetch(fetchCalls) { } test("local-api forwards strict boolean auto background sync", async () => { - const call = await runLocalSync({ auto: true, background: true }); + const call = await runLocalSync({ auto: true, background: true, nativeOnlyWsl: true }); const args = call.args; assert.deepEqual(args.slice(-4), [ path.join(process.cwd(), "bin/tracker.js"), @@ -164,6 +164,7 @@ test("local-api forwards strict boolean auto background sync", async () => { "--auto", "--background", ]); + assert.equal(call.options.env.TOKENTRACKER_WSL_MODE, "native-only"); }); test("local-api treats lightweight true as background alias", async () => { @@ -219,6 +220,33 @@ test("local-api background and lightweight require boolean true", async () => { } }); +test("local-api only propagates native-only WSL for strict boolean background requests", async () => { + const previousWslMode = process.env.TOKENTRACKER_WSL_MODE; + delete process.env.TOKENTRACKER_WSL_MODE; + + try { + const cases = [ + { + body: { auto: true, background: true, nativeOnlyWsl: true }, + expected: "native-only", + }, + { body: { auto: true, background: true, nativeOnlyWsl: false }, expected: undefined }, + { body: { auto: true, background: true, nativeOnlyWsl: "true" }, expected: undefined }, + { body: { auto: true, background: false, nativeOnlyWsl: true }, expected: undefined }, + { body: { auto: true, nativeOnlyWsl: true }, expected: undefined }, + { body: { nativeOnlyWsl: true }, expected: undefined }, + ]; + + for (const { body, expected } of cases) { + const call = await runLocalSync(body); + assert.equal(call.options.env.TOKENTRACKER_WSL_MODE, expected, JSON.stringify(body)); + } + } finally { + if (previousWslMode === undefined) delete process.env.TOKENTRACKER_WSL_MODE; + else process.env.TOKENTRACKER_WSL_MODE = previousWslMode; + } +}); + test("local-api background sync skips relayed cloud device-token issuance", async () => { const fixture = createCloudSyncHome("tokentracker-local-api-background-cloud-"); const savedBaseUrl = process.env.TOKENTRACKER_INSFORGE_BASE_URL; diff --git a/test/windows-background-sync-args.test.js b/test/windows-background-sync-args.test.js index 1386d0310..4098f5e74 100644 --- a/test/windows-background-sync-args.test.js +++ b/test/windows-background-sync-args.test.js @@ -9,8 +9,9 @@ function read(relPath) { return fs.readFileSync(path.join(repoRoot, relPath), "utf8"); } -test("Windows background sync stays native-only while manual sync preserves WSL mode", () => { +test("Windows background and manual sync keep separate execution paths", () => { const serverManager = read("TokenTrackerWin/ServerManager.cs"); + const publisher = read("TokenTrackerWin/LocalSyncPublisher.cs"); const trayContext = read("TokenTrackerWin/TrayApplicationContext.cs"); assert.match( @@ -30,32 +31,44 @@ test("Windows background sync stays native-only while manual sync preserves WSL ); assert.match( serverManager, - /public void TriggerBackgroundSync\(\)[\s\S]*StartSync\(auto: true\);/, - "Windows background sync should select the auto path", + /public void TriggerBackgroundSync\(\)[\s\S]*RunBackgroundSyncAsync\(cts\)/, + "Windows background sync should select the asynchronous local API path", ); assert.match( serverManager, - /auto\s*\?\s*new\[\]\s*\{\s*"sync",\s*"--auto",\s*"--background"\s*\}\s*:\s*new\[\]\s*\{\s*"sync"\s*\}/, - "Windows background args should use sync --auto --background while manual sync remains plain sync", + /new LocalSyncPublisher\(\s*LocalSyncHttp,\s*BaseUrl\s*\)\.PublishAsync\(/, + "Windows background sync should authenticate before posting all background flags", ); + assert.match( + publisher, + /\/api\/local-auth/, + "The extracted publisher should own the authenticated background request", + ); + assert.match(publisher, /\/functions\/tokentracker-local-sync/); + assert.match(publisher, /nativeOnlyWsl/); + const backgroundMethod = serverManager.match( + /public void TriggerBackgroundSync\(\)[\s\S]*?\n \}\r?\n\r?\n private bool StartDirectSync/, + )?.[0]; + assert.ok(backgroundMethod, "background sync method should remain discoverable"); assert.doesNotMatch( - serverManager, - /new\[\]\s*\{\s*"sync",\s*"--auto"\s*\}/, - "Windows background sync must not retain the bare sync --auto pattern", + backgroundMethod, + /StartDirectSync\(\)/, + "Windows background sync must not launch a second direct tracker process", ); assert.match( serverManager, - /StartTrackerProcess\(\s*runtime\.Value\.NodePath,\s*runtime\.Value\.EntryPath,\s*auto,\s*args\)/, - "Only the auto/background sync path should request native-only WSL isolation", + /public void TriggerSync\(\)[\s\S]*StartDirectSync\(\)/, + "Manual sync should keep the direct tracker process path", ); assert.match( serverManager, - /if \(forceNativeOnlyWslMode\)[\s\S]*psi\.Environment\["TOKENTRACKER_WSL_MODE"\]\s*=\s*"native-only";/, - "The Windows child launcher should override WSL mode for isolated background syncs", + /var args = new\[\] \{ "sync" \};[\s\S]*StartTrackerProcess\([\s\S]*\n\s*false,\s*args\)/, + "Manual sync should remain exhaustive plain sync and preserve the user's WSL mode", ); assert.match( serverManager, - /StartTrackerProcess\(\s*nodePath,\s*entryPath,\s*false,\s*"serve"/, - "The long-lived server must not receive the background-only WSL override", + /if \(forceNativeOnlyWslMode\)[\s\S]*psi\.Environment\["TOKENTRACKER_WSL_MODE"\]\s*=\s*"native-only";/, + "The child launcher should retain the explicit WSL environment hook for authorized paths", ); + assert.match(serverManager, /StartTrackerProcess\(\s*nodePath,\s*entryPath,\s*false,\s*"serve"/); }); diff --git a/test/windows-background-sync-source.test.js b/test/windows-background-sync-source.test.js index 17e6fc63c..b04619377 100644 --- a/test/windows-background-sync-source.test.js +++ b/test/windows-background-sync-source.test.js @@ -9,8 +9,9 @@ function read(relPath) { return fs.readFileSync(path.join(repoRoot, relPath), "utf8"); } -test("Windows high-frequency background sync uses explicit background args", () => { +test("Windows background sync publishes through the authenticated local API", () => { const serverManager = read("TokenTrackerWin/ServerManager.cs"); + const publisher = read("TokenTrackerWin/LocalSyncPublisher.cs"); const trayContext = read("TokenTrackerWin/TrayApplicationContext.cs"); assert.match(trayContext, /new\(\)\s*\{\s*Interval\s*=\s*5\s*\*\s*60\s*\*\s*1000\s*\}/); @@ -19,10 +20,38 @@ test("Windows high-frequency background sync uses explicit background args", () trayContext, /ServerStatus\.Running[\s\S]*_syncTimer\.Start\(\);[\s\S]*TriggerBackgroundSync\(\);/, ); - assert.match(serverManager, /public void TriggerBackgroundSync\(\)[\s\S]*StartSync\(auto: true\);/); assert.match( serverManager, - /auto\s*\?\s*new\[\]\s*\{\s*"sync",\s*"--auto",\s*"--background"\s*\}\s*:\s*new\[\]\s*\{\s*"sync"\s*\}/, + /public void TriggerBackgroundSync\(\)[\s\S]*RunBackgroundSyncAsync\(cts\)/, + "Windows timer sync should run asynchronously through the local API", + ); + assert.match( + serverManager, + /new LocalSyncPublisher\(\s*LocalSyncHttp,\s*BaseUrl\s*\)\.PublishAsync\(/, + "ServerManager should delegate the authenticated exchange to the tested publisher", + ); + assert.match(publisher, /\/api\/local-auth/); + assert.match( + publisher, + /HttpMethod\.Post[\s\S]*\/functions\/tokentracker-local-sync/, + ); + assert.match(publisher, /x-tokentracker-local-auth/); + assert.match( + publisher, + /"\{\\"auto\\":true,\\"background\\":true,\\"allLocalSources\\":true,\\"publishAccount\\":true,\\"nativeOnlyWsl\\":true\}"/, + "Windows background sync should publish all local sources and request native-only WSL", + ); + assert.match( + serverManager, + /LocalSyncHttp[\s\S]*UseProxy\s*=\s*false[\s\S]*Timeout\s*=\s*Timeout\.InfiniteTimeSpan/, + "Background local sync must let the server own its complete budget and cancel on shutdown", + ); + assert.match(serverManager, /SyncStarted\?\.Invoke\(\)/); + assert.match(serverManager, /SyncCompleted\?\.Invoke\(\)/); + assert.match(serverManager, /public void TriggerSync\(\)[\s\S]*StartDirectSync\(\)/); + assert.match( + serverManager, + /var args = new\[\] \{ "sync" \};[\s\S]*StartTrackerProcess\([\s\S]*\n\s*false,\s*args\)/, + "Manual sync should remain the direct exhaustive plain sync path", ); - assert.doesNotMatch(serverManager, /new\[\]\s*\{\s*"sync",\s*"--auto"\s*\}/); });