Init() created a new SynologyClient and logged in for every download. A many-file download opened dozens of near-simultaneous sessions for one DSM account; DSM invalidates older session IDs, so in-flight File Station calls failed with error 119 "SID not found". Add SynologyClientProvider: a thread-safe cache that authenticates once and shares the client across downloads (concurrent first calls collapse to a single login; later calls reuse it; a credential change re-authenticates). Wrap every Synology call in a session-retry that drops the session and re-authenticates once on a session error (119/106/107) so an expired SID self-heals, reading credentials fresh from settings so edits take effect live. Adds provider unit tests and a downloader test covering the 119 re-auth path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
3.8 KiB
C#
124 lines
3.8 KiB
C#
using Synology.Api.Client;
|
|
|
|
namespace RdtClient.Service.Services.Downloaders;
|
|
|
|
/// <summary>
|
|
/// Holds a single authenticated <see cref="ISynologyClient" /> shared across all Download Station downloads.
|
|
/// Logging in once instead of once per download avoids the concurrent-login session churn that made DSM return
|
|
/// error 119 "SID not found" under high file-count concurrency. The session is created on demand, reused while
|
|
/// the credentials are unchanged, and dropped (via <see cref="InvalidateAsync" />) when a download hits a session
|
|
/// error so the next caller logs in again.
|
|
/// </summary>
|
|
public static class SynologyClientProvider
|
|
{
|
|
private static readonly Func<String, String, String, Task<ISynologyClient>> DefaultClientFactory = async (url, username, password) =>
|
|
{
|
|
var client = new SynologyClient(url);
|
|
await client.LoginAsync(username, password);
|
|
|
|
return client;
|
|
};
|
|
|
|
private static readonly SemaphoreSlim Gate = new(1, 1);
|
|
|
|
private static ISynologyClient? _client;
|
|
private static (String Url, String Username, String Password)? _key;
|
|
|
|
/// <summary>
|
|
/// Builds an authenticated client. Overridable so tests can supply a fake without a live DSM. Restored by
|
|
/// <see cref="Reset" />.
|
|
/// </summary>
|
|
public static Func<String, String, String, Task<ISynologyClient>> ClientFactory { get; set; } = DefaultClientFactory;
|
|
|
|
/// <summary>
|
|
/// Returns the shared authenticated client for these credentials, logging in once. Concurrent first calls
|
|
/// collapse to a single login; later calls reuse the cached client.
|
|
/// </summary>
|
|
public static async Task<ISynologyClient> GetClientAsync(String url, String username, String password)
|
|
{
|
|
var key = (url, username, password);
|
|
|
|
// Fast path: a client for these exact credentials already exists.
|
|
var current = _client;
|
|
|
|
if (current != null && _key == key)
|
|
{
|
|
return current;
|
|
}
|
|
|
|
await Gate.WaitAsync();
|
|
|
|
try
|
|
{
|
|
if (_client != null && _key == key)
|
|
{
|
|
return _client;
|
|
}
|
|
|
|
// No client yet, or the credentials changed: drop any stale session and authenticate fresh.
|
|
await TryLogoutAsync(_client);
|
|
|
|
_client = await ClientFactory(url, username, password);
|
|
_key = key;
|
|
|
|
return _client;
|
|
}
|
|
finally
|
|
{
|
|
Gate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops the cached session so the next <see cref="GetClientAsync" /> authenticates again. Pass the client that
|
|
/// just failed so a session another caller has already refreshed is not thrown away.
|
|
/// </summary>
|
|
public static async Task InvalidateAsync(ISynologyClient? failedClient = null)
|
|
{
|
|
await Gate.WaitAsync();
|
|
|
|
try
|
|
{
|
|
if (failedClient != null && !ReferenceEquals(_client, failedClient))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await TryLogoutAsync(_client);
|
|
|
|
_client = null;
|
|
_key = null;
|
|
}
|
|
finally
|
|
{
|
|
Gate.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the cached session and restores the default factory. For test isolation.
|
|
/// </summary>
|
|
public static void Reset()
|
|
{
|
|
_client = null;
|
|
_key = null;
|
|
ClientFactory = DefaultClientFactory;
|
|
}
|
|
|
|
private static async Task TryLogoutAsync(ISynologyClient? client)
|
|
{
|
|
if (client == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await client.LogoutAsync();
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort: an expired or dead session cannot be logged out, and that is fine.
|
|
}
|
|
}
|
|
}
|