Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions TokenTrackerWin.Tests/LocalSyncPublisherTests.cs
Original file line number Diff line number Diff line change
@@ -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<CapturedRequest>();
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<HttpRequestException>(
() => 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<InvalidOperationException>(
() => 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<bool>(
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<OperationCanceledException>(() => 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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> script)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
=> script(request, cancellationToken);
}
}
1 change: 1 addition & 0 deletions TokenTrackerWin.Tests/TokenTrackerWin.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

<ItemGroup>
<Compile Include="../TokenTrackerWin/ChildProcessProxy.cs" Link="ChildProcessProxy.cs" />
<Compile Include="../TokenTrackerWin/LocalSyncPublisher.cs" Link="LocalSyncPublisher.cs" />
<Compile Include="../TokenTrackerWin/ResumableDownloader.cs" Link="ResumableDownloader.cs" />
</ItemGroup>

Expand Down
77 changes: 77 additions & 0 deletions TokenTrackerWin/LocalSyncPublisher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;

namespace TokenTrackerWin;

/// <summary>
/// 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.
/// </summary>
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.");
}
}
74 changes: 66 additions & 8 deletions TokenTrackerWin/ServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };

/// <summary>Raised on the thread-pool when the running state flips. UI must marshal to the UI thread.</summary>
public event Action<ServerStatus>? StatusChanged;

Expand Down Expand Up @@ -90,31 +100,43 @@ public async Task EnsureServerRunningAsync()
/// <summary>Run a one-shot `tracker sync` against the resolved runtime.</summary>
public void TriggerSync()
{
StartSync(auto: false);
StartDirectSync();
}

/// <summary>Run a quiet, non-overlapping background sync for live tray totals.</summary>
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 += (_, _) =>
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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); }
Expand Down
3 changes: 3 additions & 0 deletions src/lib/local-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading