Move TorMonitor functionality to TorProcessManager.
MODIFY
WalletWasabi.Tests/IntegrationTests/BlockstreamInfoTests.cs
WalletWasabi.Tests/IntegrationTests/BlockstreamInfoTests.cs
@@ -27,8 +27,7 @@ namespace WalletWasabi.Tests.IntegrationTests
public async Task InitializeAsync()
{
- bool started = await TorManager.StartAsync();
- Assert.True(started, "Tor failed to start.");
+ await TorManager.StartAsync();
}
public async Task DisposeAsync()

MODIFY
WalletWasabi.Tests/IntegrationTests/LiveServerTests.cs
WalletWasabi.Tests/IntegrationTests/LiveServerTests.cs
@@ -37,8 +37,7 @@ namespace WalletWasabi.Tests.IntegrationTests
public async Task InitializeAsync()
{
- bool started = await TorManager.StartAsync();
- Assert.True(started, "Tor failed to start.");
+ await TorManager.StartAsync();
}
public async Task DisposeAsync()

MODIFY
WalletWasabi.Tests/IntegrationTests/TorTests.cs
WalletWasabi.Tests/IntegrationTests/TorTests.cs
@@ -27,8 +27,7 @@ namespace WalletWasabi.Tests.IntegrationTests
public async Task InitializeAsync()
{
- bool started = await TorManager.StartAsync();
- Assert.True(started, "Tor failed to start.");
+ await TorManager.StartAsync();
}
public async Task DisposeAsync()

ADD
WalletWasabi.Tests/UnitTests/Tor/CancellationTokenExtensions.cs
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WalletWasabi.Tests.UnitTests.Tor
+{
+ public static class CancellationTokenExtensions
+ {
+ /// <seealso href="https://github.com/dotnet/runtime/issues/14991#issuecomment-131221355"/>
+ public static Task WhenCanceled(this CancellationToken cancellationToken)
+ {
+ TaskCompletionSource<bool> tcs = new();
+ cancellationToken.Register(s => ((TaskCompletionSource<bool>)s!).SetResult(true), tcs);
+ return tcs.Task;
+ }
+ }
+}

MODIFY
WalletWasabi.Tests/UnitTests/Tor/TorProcessManagerTests.cs
WalletWasabi.Tests/UnitTests/Tor/TorProcessManagerTests.cs
@@ -20,45 +20,58 @@ namespace WalletWasabi.Tests.UnitTests.Tor
private static readonly IPEndPoint DummyTorControlEndpoint = new(IPAddress.Loopback, 7777);
/// <summary>
- /// Verifies that in case Tor process is successfully started and Tor Control is set up,
- /// <see cref="TorProcessManager.StartAsync(CancellationToken)"/> returns <c>true</c>.
+ /// Simulates: Tor OS process is fully started. The process crashes after 2 seconds.
+ /// Then we simulate then user asked to cancel TorProcessManager as the application shuts down.
/// </summary>
[Fact]
public async Task StartProcessAsync()
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromMinutes(2));
+ // Test parameter: Simulate that Tor process crashes after two seconds.
+ TimeSpan torProcessCrashPeriod = TimeSpan.FromSeconds(2);
+
// Tor settings.
string dataDir = Path.Combine("temp", "tempDataDir");
string distributionFolder = "tempDistributionDir";
TorSettings settings = new(dataDir, distributionFolder, terminateOnExit: true, owningProcessId: 7);
- // Dummy Tor process.
+ // Mock Tor process.
Mock<ProcessAsync> mockProcess = new(MockBehavior.Strict, new ProcessStartInfo());
mockProcess.Setup(p => p.WaitForExitAsync(It.IsAny<CancellationToken>(), It.IsAny<bool>()))
- .Returns(async (CancellationToken cancellationToken, bool killOnCancel) => await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false));
-
- // Set up Tor control client.
- Pipe toServer = new();
- Pipe toClient = new();
- await using TorControlClient controlClient = new(pipeReader: toClient.Reader, pipeWriter: toServer.Writer);
+ .Returns((CancellationToken cancellationToken, bool killOnCancel) => Task.Delay(torProcessCrashPeriod, cancellationToken));
+ mockProcess.Setup(p => p.Dispose());
// Set up Tor process manager.
Mock<TorTcpConnectionFactory> mockTcpConnectionFactory = new(MockBehavior.Strict, DummyTorControlEndpoint);
mockTcpConnectionFactory.Setup(c => c.IsTorRunningAsync())
.ReturnsAsync(false);
+ // Mock TorProcessManager.
Mock<TorProcessManager> mockTorProcessManager = new(MockBehavior.Strict, settings, mockTcpConnectionFactory.Object) { CallBase = true };
mockTorProcessManager.Setup(c => c.StartProcess(It.IsAny<string>()))
.Returns(mockProcess.Object);
mockTorProcessManager.Setup(c => c.EnsureRunningAsync(It.IsAny<ProcessAsync>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
- mockTorProcessManager.Setup(c => c.InitTorControlAsync(It.IsAny<CancellationToken>()))
- .ReturnsAsync(controlClient);
+ mockTorProcessManager.SetupSequence(c => c.InitTorControlAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(() => new TorControlClient(pipeReader: new Pipe().Reader, pipeWriter: new Pipe().Writer)) // (1)
+ .ThrowsAsync(new OperationCanceledException()); // (2)
+
+ await using (TorProcessManager manager = mockTorProcessManager.Object)
+ {
+ // No exception is expected here.
+ CancellationToken ct1 = await manager.StartAsync(timeoutCts.Token);
+
+ // Wait for the Tor process crash (see (1)).
+ await ct1.WhenCanceled().WithAwaitCancellationAsync(timeoutCts.Token);
+
+ // Wait until TorProcessManager is stopped (see (2)).
+ CancellationToken ct2 = await manager.WaitForNextAttemptAsync(timeoutCts.Token);
+ await ct2.WhenCanceled().WithAwaitCancellationAsync(timeoutCts.Token);
+ }
- TorProcessManager manager = mockTorProcessManager.Object;
- bool result = await manager.StartAsync(timeoutCts.Token);
- Assert.True(result);
+ mockTorProcessManager.Verify(c => c.StartProcess(It.IsAny<string>()), Times.Exactly(2));
+ mockTorProcessManager.VerifyAll();
}
}
}

MODIFY
WalletWasabi/Microservices/ProcessAsync.cs
WalletWasabi/Microservices/ProcessAsync.cs
@@ -90,8 +90,7 @@ namespace WalletWasabi.Microservices
try
{
- Logger.LogTrace($"Wait for the process to exit: '{Process.StartInfo.FileName}'");
-
+ Logger.LogTrace($"Wait for the process to exit: '{Process.Id}'");
await Process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
Logger.LogTrace("Process has exited.");
@@ -137,7 +136,7 @@ namespace WalletWasabi.Microservices
_disposed = true;
}
- public void Dispose()
+ public virtual void Dispose()
{
// Dispose of unmanaged resources.
Dispose(true);

MODIFY
WalletWasabi/Tor/TorMonitor.cs
WalletWasabi/Tor/TorMonitor.cs
@@ -62,16 +62,7 @@ namespace WalletWasabi.Tor
{
bool isRunning = await HttpClient.IsTorRunningAsync().ConfigureAwait(false);
- if (!isRunning)
- {
- Logger.LogInfo($"Tor did not work properly for {(int)torMisbehavedFor.TotalSeconds} seconds. Maybe it crashed. Attempting to start it...");
-
- // Try starting Tor, if it does not work it'll be another issue.
- bool started = await TorProcessManager.StartAsync(token).ConfigureAwait(false);
-
- Logger.LogInfo($"Tor re-starting attempt {(started ? "succeeded." : "FAILED. Will try again later.")}");
- }
- else
+ if (isRunning)
{
Logger.LogInfo("Tor is running. Waiting for a confirmation that HTTP requests can pass through.");
}

MODIFY
WalletWasabi/Tor/TorProcessManager.cs
WalletWasabi/Tor/TorProcessManager.cs
@@ -17,7 +17,8 @@ namespace WalletWasabi.Tor
/// <seealso href="https://2019.www.torproject.org/docs/tor-manual.html.en"/>
public class TorProcessManager : IAsyncDisposable
{
- private bool _disposed = false;
+ /// <summary>Task competion source returning a cancellation token which is canceled when Tor process is terminated.</summary>
+ private volatile TaskCompletionSource<CancellationToken> _tcs = new();
public TorProcessManager(TorSettings settings) :
this(settings, new(settings.SocksEndpoint))
@@ -29,24 +30,63 @@ namespace WalletWasabi.Tor
{
TorProcess = null;
TorControlClient = null;
+ LoopCts = new();
+ LoopTask = null;
Settings = settings;
TcpConnectionFactory = tcpConnectionFactory;
}
- private ProcessAsync? TorProcess { get; set; }
+ /// <summary>Guards <see cref="TorProcess"/> and <see cref="TorControlClient"/>.</summary>
+ private object StateLock { get; } = new();
+
+ private Task? LoopTask { get; set; }
+
+ /// <summary>To stop the loop that keeps starting Tor process.</summary>
+ private CancellationTokenSource LoopCts { get; }
private TorSettings Settings { get; }
private TorTcpConnectionFactory TcpConnectionFactory { get; }
+ /// <remarks>Guarded by <see cref="StateLock"/>.</remarks>
+ private ProcessAsync? TorProcess { get; set; }
+
+ /// <remarks>Guarded by <see cref="StateLock"/>.</remarks>
private TorControlClient? TorControlClient { get; set; }
- /// <summary>Starts Tor process if it is not running already.</summary>
+ /// <summary>Starts loop which makes sure that Tor process is started.</summary>
+ /// <param name="cancellationToken">Application lifetime cancellation token.</param>
+ /// <returns>Cancellation token which is canceled once Tor process terminates (either forcefully or gracefully).</returns>
+ /// <remarks>This method must be called exactly once.</remarks>
/// <exception cref="OperationCanceledException"/>
- public async Task<bool> StartAsync(CancellationToken token = default)
+ public async Task<CancellationToken> StartAsync(CancellationToken cancellationToken = default)
+ {
+ LoopTask = RestartingLoopAsync(cancellationToken);
+
+ return await WaitForNextAttemptAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <summary>Waits until Tor process is fully started or until it is stopped for some reason.</summary>
+ /// <returns>Cancellation token which is canceled once Tor process terminates.</returns>
+ /// <remarks>This is useful to set up Tor control monitors that need to be restarted once Tor process is started again.</remarks>
+ public Task<CancellationToken> WaitForNextAttemptAsync(CancellationToken cancellationToken = default)
{
- ThrowIfDisposed();
+ return _tcs.Task.WithAwaitCancellationAsync(cancellationToken);
+ }
+ /// <summary>Keeps starting Tor OS process.</summary>
+ /// <param name="globalCancellationToken">Application lifetime cancellation token.</param>
+ private async Task RestartingLoopAsync(CancellationToken globalCancellationToken)
+ {
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(globalCancellationToken, LoopCts.Token);
+ CancellationToken cancellationToken = linkedCts.Token;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
ProcessAsync? process = null;
TorControlClient? controlClient = null;
+ bool setNewTcs = true;
+
+ // Use CancellationTokenSource to signal that Tor process terminated.
+ using CancellationTokenSource cts = new();
try
{
@@ -56,51 +96,81 @@ namespace WalletWasabi.Tor
if (isAlreadyRunning)
{
Logger.LogInfo($"Tor is already running on {Settings.SocksEndpoint.Address}:{Settings.SocksEndpoint.Port}.");
- TorControlClient = await InitTorControlAsync(token).ConfigureAwait(false);
- return true;
- }
+ controlClient = await InitTorControlAsync(cancellationToken).ConfigureAwait(false);
+ // Tor process can crash even between these two commands too.
+ int processId = await controlClient.GetTorProcessIdAsync(cancellationToken).ConfigureAwait(false);
+ process = new ProcessAsync(Process.GetProcessById(processId));
+ }
+ else
+ {
string arguments = Settings.GetCmdArguments();
process = StartProcess(arguments);
- bool isRunning = await EnsureRunningAsync(process, token).ConfigureAwait(false);
+ bool isRunning = await EnsureRunningAsync(process, cancellationToken).ConfigureAwait(false);
if (!isRunning)
{
- return false;
+ Logger.LogTrace("Failed to start Tor process. Trying again.");
+ continue;
+ }
+
+ controlClient = await InitTorControlAsync(cancellationToken).ConfigureAwait(false);
}
- controlClient = await InitTorControlAsync(token).ConfigureAwait(false);
Logger.LogInfo("Tor is running.");
+ _tcs.SetResult(cts.Token);
// Only now we know that Tor process is fully started.
+ lock (StateLock)
+ {
TorProcess = process;
TorControlClient = controlClient;
- controlClient = null;
- process = null;
+ }
- return true;
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex)
{
Logger.LogDebug("User canceled operation.", ex);
- throw;
+ setNewTcs = false;
+ break;
}
catch (Exception ex)
{
- Logger.LogError("Could not automatically start Tor. Try running Tor manually.", ex);
+ Logger.LogError("Unexpected problem in starting Tor.", ex);
+ setNewTcs = false;
+ throw;
}
finally
{
+ TaskCompletionSource<CancellationToken> originalTcs = _tcs;
+
+ if (setNewTcs)
+ {
+ // (1) and (2) must be in this order. Otherwise, there is a race condition risk of getting invalid CT by clients.
+ TaskCompletionSource<CancellationToken> newTcs = new();
+ originalTcs = Interlocked.Exchange(ref _tcs, newTcs); // (1)
+ }
+
+ cts.Cancel(); // (2)
+ originalTcs.TrySetResult(cts.Token);
+ cts.Dispose();
+
if (controlClient is not null)
{
await controlClient.DisposeAsync().ConfigureAwait(false);
}
process?.Dispose();
- }
- return false;
+ lock (StateLock)
+ {
+ TorProcess = null;
+ TorControlClient = null;
+ }
+ }
+ }
}
/// <summary>Ensure <paramref name="process"/> is actually running.</summary>
@@ -203,9 +273,25 @@ namespace WalletWasabi.Tor
public async ValueTask DisposeAsync()
{
- _disposed = true;
+ LoopCts.Cancel();
+
+ if (LoopTask is Task t)
+ {
+ await t.ConfigureAwait(false);
+ }
+
+ LoopCts.Dispose();
+
+ ProcessAsync? process;
+ TorControlClient? torControlClient;
+
+ lock (StateLock)
+ {
+ process = TorProcess;
+ torControlClient = TorControlClient;
+ }
- if (TorControlClient is TorControlClient torControlClient)
+ if (torControlClient is TorControlClient)
{
// Even though terminating the TCP connection with Tor would shut down Tor,
// the spec is quite clear:
@@ -223,15 +309,7 @@ namespace WalletWasabi.Tor
}
// Dispose Tor process resources (does not stop/kill Tor process).
- TorProcess?.Dispose();
- }
-
- private void ThrowIfDisposed()
- {
- if (_disposed)
- {
- throw new ObjectDisposedException(GetType().FullName);
- }
+ process?.Dispose();
}
}
}

Move TorMonitor functionality to TorProcessManager.