Skip to content

Commit 5e2c12e

Browse files
Experimenting with named mutexes and semaphores
1 parent 83a3c75 commit 5e2c12e

File tree

8 files changed

+218
-55
lines changed

8 files changed

+218
-55
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using Sentry.Extensibility;
2+
using Sentry.Internal.Extensions;
3+
4+
namespace Sentry.Internal;
5+
6+
internal class CacheDirectoryCoordinator : IDisposable
7+
{
8+
private readonly IDiagnosticLogger? _logger;
9+
private readonly Semaphore _semaphore;
10+
private bool _acquired;
11+
private bool _disposed;
12+
private readonly Lock _gate = new();
13+
14+
public CacheDirectoryCoordinator(string cacheDir, IDiagnosticLogger? logger)
15+
{
16+
_logger = logger;
17+
var mutexName = BuildMutexName(cacheDir);
18+
_semaphore = new Semaphore(1, 1, mutexName); // Named mutexes allow interprocess locks on all platforms
19+
}
20+
21+
private static string BuildMutexName(string path)
22+
{
23+
var hash = path.GetHashString();
24+
// Global\ prefix allows cross-session visibility on Windows Terminal Services
25+
return $"Global\\SentryCache{hash}";
26+
}
27+
28+
/// <summary>
29+
/// Try to own this cache directory.
30+
/// </summary>
31+
/// <param name="timeout">How long to wait for the lock.</param>
32+
/// <returns>True if acquired; false otherwise.</returns>
33+
public bool TryAcquire(TimeSpan timeout)
34+
{
35+
if (_acquired)
36+
{
37+
return true;
38+
}
39+
lock (_gate)
40+
{
41+
if (_acquired)
42+
{
43+
return true;
44+
}
45+
if (!_semaphore.WaitOne(timeout))
46+
{
47+
return false;
48+
}
49+
_acquired = true;
50+
return true;
51+
}
52+
}
53+
54+
public void Dispose()
55+
{
56+
lock (_gate)
57+
{
58+
if (_disposed)
59+
return;
60+
61+
_disposed = true;
62+
if (_acquired)
63+
{
64+
try { _semaphore.Release(); }
65+
catch (Exception ex) { _logger?.LogError("Error releasing the cache directory semaphore.", ex); }
66+
}
67+
_semaphore.Dispose();
68+
}
69+
}
70+
}
71+
72+
internal static class CacheDirectoryHelper
73+
{
74+
public const string IsolatedCacheDirectoryPrefix = "isolated_";
75+
76+
internal static string? GetBaseCacheDirectoryPath(this SentryOptions options) =>
77+
string.IsNullOrWhiteSpace(options.CacheDirectoryPath)
78+
? null
79+
: Path.Combine(options.CacheDirectoryPath, "Sentry");
80+
81+
internal static string? TryGetIsolatedCacheDirectoryPath(this SentryOptions options)
82+
{
83+
if (GetBaseCacheDirectoryPath(options) is not {} baseCacheDir || string.IsNullOrWhiteSpace(options.Dsn))
84+
{
85+
return null;
86+
}
87+
88+
var stringBuilder = new StringBuilder(IsolatedCacheDirectoryPrefix);
89+
#if IOS || ANDROID
90+
// On iOS or Android the app is already sandboxed, so there's no risk of sending data to another Sentry's DSN.
91+
// However, users may still initiate the SDK multiple times within the process, so we need an InitCounter
92+
stringBuilder.Append(options.InitCounter.Count);
93+
#else
94+
var processId = options.ProcessIdResolver.Invoke() ?? 0;
95+
stringBuilder.AppendJoin('_', options.Dsn.GetHashString(), processId, options.InitCounter.Count);
96+
#endif
97+
return Path.Combine(baseCacheDir, stringBuilder.ToString());
98+
}
99+
}

src/Sentry/Internal/FileSystemBase.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ namespace Sentry.Internal;
22

33
internal abstract class FileSystemBase : IFileSystem
44
{
5+
public IEnumerable<string> EnumerateDirectories(string path, string searchPattern) =>
6+
Directory.EnumerateDirectories(path, searchPattern);
7+
58
public IEnumerable<string> EnumerateFiles(string path) => Directory.EnumerateFiles(path);
69

710
public IEnumerable<string> EnumerateFiles(string path, string searchPattern) =>

src/Sentry/Internal/Http/CachingTransport.cs

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ internal class CachingTransport : ITransport, IDisposable
2222
private readonly ITransport _innerTransport;
2323
private readonly SentryOptions _options;
2424
private readonly bool _failStorage;
25-
private readonly string _isolatedCacheDirectoryPath;
2625
private readonly int _keepCount;
2726

27+
private readonly CacheDirectoryCoordinator _cacheCoordinator;
28+
private readonly string _isolatedCacheDirectoryPath;
29+
2830
// When a file is getting processed, it's moved to a child directory
2931
// to avoid getting picked up by other threads.
3032
private readonly string _processingDirectoryPath;
@@ -45,6 +47,7 @@ internal class CachingTransport : ITransport, IDisposable
4547

4648
private readonly CancellationTokenSource _workerCts = new();
4749
private Task _worker = null!;
50+
private Task? _recycler = null;
4851

4952
private ManualResetEventSlim? _initCacheResetEvent = new();
5053
private ManualResetEventSlim? _preInitCacheResetEvent = new();
@@ -77,6 +80,11 @@ private CachingTransport(ITransport innerTransport, SentryOptions options, bool
7780
_isolatedCacheDirectoryPath =
7881
options.TryGetIsolatedCacheDirectoryPath() ??
7982
throw new InvalidOperationException("Cache directory or DSN is not set.");
83+
_cacheCoordinator = new CacheDirectoryCoordinator(_isolatedCacheDirectoryPath, options.DiagnosticLogger);
84+
if (!_cacheCoordinator.TryAcquire(TimeSpan.FromMilliseconds(100)))
85+
{
86+
throw new InvalidOperationException("Cache directory already locked.");
87+
}
8088

8189
// Sanity check: This should never happen in the first place.
8290
// We check for `DisableFileWrite` before creating the CachingTransport.
@@ -99,6 +107,9 @@ private void Initialize(bool startWorker)
99107
{
100108
_options.LogDebug("Starting CachingTransport worker.");
101109
_worker = Task.Run(CachedTransportBackgroundTaskAsync);
110+
111+
// Start a recycler in the background so as not to block initialisation
112+
// _recycler = Task.Run(SalvageAbandonedCacheSessions);
102113
}
103114
else
104115
{
@@ -178,6 +189,36 @@ private async Task CachedTransportBackgroundTaskAsync()
178189
_options.LogDebug("CachingTransport worker stopped.");
179190
}
180191

192+
private void SalvageAbandonedCacheSessions()
193+
{
194+
if (_options.GetBaseCacheDirectoryPath() is not { } baseCacheDir)
195+
{
196+
return;
197+
}
198+
199+
const string searchPattern = $"{CacheDirectoryHelper.IsolatedCacheDirectoryPrefix}*";
200+
foreach (var dir in _fileSystem.EnumerateDirectories(baseCacheDir, searchPattern))
201+
{
202+
if (string.Equals(dir, _isolatedCacheDirectoryPath, StringComparison.OrdinalIgnoreCase))
203+
{
204+
continue; // Ignore the current cache directory
205+
}
206+
207+
_options.LogDebug("found abandoned cache: {0}", dir);
208+
using var coordinator = new CacheDirectoryCoordinator(dir, _options.DiagnosticLogger);
209+
if (coordinator.TryAcquire(TimeSpan.FromMilliseconds(100)))
210+
{
211+
_options.LogDebug("Salvaging abandoned cache: {0}", dir);
212+
foreach (var filePath in _fileSystem.EnumerateFiles (dir, "*.envelope", SearchOption.AllDirectories))
213+
{
214+
var destinationPath = Path.Combine(_isolatedCacheDirectoryPath, Path.GetFileName(filePath));
215+
_options.LogDebug("Moving abandoned file back to cache: {0} to {1}.", filePath, destinationPath);
216+
MoveFileWithRetries(filePath, destinationPath, "abandoned");
217+
}
218+
}
219+
}
220+
}
221+
181222
private void MoveUnprocessedFilesBackToCache()
182223
{
183224
// Processing directory may already contain some files left from previous session
@@ -194,42 +235,46 @@ private void MoveUnprocessedFilesBackToCache()
194235
{
195236
var destinationPath = Path.Combine(_isolatedCacheDirectoryPath, Path.GetFileName(filePath));
196237
_options.LogDebug("Moving unprocessed file back to cache: {0} to {1}.", filePath, destinationPath);
238+
MoveFileWithRetries(filePath, destinationPath, "unprocessed");
239+
}
240+
}
197241

198-
const int maxAttempts = 3;
199-
for (var attempt = 1; attempt <= maxAttempts; attempt++)
242+
private void MoveFileWithRetries(string filePath, string destinationPath, string moveType)
243+
{
244+
const int maxAttempts = 3;
245+
for (var attempt = 1; attempt <= maxAttempts; attempt++)
246+
{
247+
try
200248
{
201-
try
249+
_fileSystem.MoveFile(filePath, destinationPath);
250+
break;
251+
}
252+
catch (Exception ex)
253+
{
254+
if (!_fileSystem.FileExists(filePath))
202255
{
203-
_fileSystem.MoveFile(filePath, destinationPath);
256+
_options.LogDebug(
257+
"Failed to move {2} file back to cache (attempt {0}), " +
258+
"but the file no longer exists so it must have been handled by another process: {1}",
259+
attempt, filePath, moveType);
204260
break;
205261
}
206-
catch (Exception ex)
207-
{
208-
if (!_fileSystem.FileExists(filePath))
209-
{
210-
_options.LogDebug(
211-
"Failed to move unprocessed file back to cache (attempt {0}), " +
212-
"but the file no longer exists so it must have been handled by another process: {1}",
213-
attempt, filePath);
214-
break;
215-
}
216262

217-
if (attempt < maxAttempts)
218-
{
219-
_options.LogDebug(
220-
"Failed to move unprocessed file back to cache (attempt {0}, retrying.): {1}",
221-
attempt, filePath);
222-
223-
Thread.Sleep(200); // give a small bit of time before retry
224-
}
225-
else
226-
{
227-
_options.LogError(ex,
228-
"Failed to move unprocessed file back to cache (attempt {0}, done.): {1}", attempt, filePath);
229-
}
263+
if (attempt < maxAttempts)
264+
{
265+
_options.LogDebug(
266+
"Failed to move {2} file back to cache (attempt {0}, retrying.): {1}",
267+
attempt, filePath, moveType);
230268

231-
// note: we do *not* want to re-throw the exception
269+
Thread.Sleep(200); // give a small bit of time before retry
232270
}
271+
else
272+
{
273+
_options.LogError(ex,
274+
"Failed to move {2} file back to cache (attempt {0}, done.): {1}", attempt, filePath, moveType);
275+
}
276+
277+
// note: we do *not* want to re-throw the exception
233278
}
234279
}
235280
}
@@ -560,7 +605,9 @@ public async ValueTask DisposeAsync()
560605
_workerSignal.Dispose();
561606
_workerCts.Dispose();
562607
_worker.Dispose();
608+
_recycler?.Dispose();
563609
_cacheDirectoryLock.Dispose();
610+
_cacheCoordinator.Dispose();
564611
_preInitCacheResetEvent?.Dispose();
565612
_initCacheResetEvent?.Dispose();
566613

src/Sentry/Internal/IFileSystem.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal interface IFileSystem
1010
// Note: This is not comprehensive. If you need other filesystem methods, add to this interface,
1111
// then implement in both Sentry.Internal.FileSystem and Sentry.Testing.FakeFileSystem.
1212

13+
public IEnumerable<string> EnumerateDirectories(string path, string searchPattern);
1314
public IEnumerable<string> EnumerateFiles(string path);
1415
public IEnumerable<string> EnumerateFiles(string path, string searchPattern);
1516
public IEnumerable<string> EnumerateFiles(string path, string searchPattern, SearchOption searchOption);

src/Sentry/Internal/Polyfills.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
// Polyfills to bridge the missing APIs in older targets.
22

3+
#if !NET9_0_OR_GREATER
4+
global using Lock = object;
5+
#endif
6+
37
#if NETFRAMEWORK || NETSTANDARD2_0
48
namespace System
59
{
@@ -9,6 +13,32 @@ internal static class HashCode
913
public static int Combine<T1, T2, T3>(T1 value1, T2 value2, T3 value3) => (value1, value2, value3).GetHashCode();
1014
}
1115
}
16+
17+
internal static partial class PolyfillExtensions
18+
{
19+
public static StringBuilder AppendJoin(this StringBuilder builder, char separator, params object?[] values)
20+
{
21+
if (values.Length == 0)
22+
{
23+
return builder;
24+
}
25+
26+
if (values[0] is {} value)
27+
{
28+
builder.Append(value);
29+
}
30+
31+
for (var i = 1; i < values.Length; i++)
32+
{
33+
builder.Append(separator);
34+
if (values[i] is {} nextValue)
35+
{
36+
builder.Append(nextValue);
37+
}
38+
}
39+
return builder;
40+
}
41+
}
1242
#endif
1343

1444
#if NETFRAMEWORK

src/Sentry/SentryOptions.cs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,28 +1806,6 @@ internal void SetupLogging()
18061806
}
18071807
}
18081808

1809-
internal string? TryGetIsolatedCacheDirectoryPath()
1810-
{
1811-
if (string.IsNullOrWhiteSpace(CacheDirectoryPath))
1812-
{
1813-
return null;
1814-
}
1815-
1816-
// DSN must be set to use caching
1817-
if (string.IsNullOrWhiteSpace(Dsn))
1818-
{
1819-
return null;
1820-
}
1821-
1822-
#if IOS || ANDROID // on iOS or Android the app is already sandboxed so there's no risk of sending data from 1 app to another Sentry's DSN
1823-
return Path.Combine(CacheDirectoryPath, "Sentry");
1824-
#else
1825-
var processId = ProcessIdResolver.Invoke() ?? 0;
1826-
var stringBuilder = new StringBuilder().AppendJoin('_', Dsn.GetHashCode(), processId, InitCounter.Count);
1827-
return Path.Combine(CacheDirectoryPath, "Sentry", stringBuilder.ToString());
1828-
#endif
1829-
}
1830-
18311809
internal static List<StringOrRegex> GetDefaultInAppExclude() =>
18321810
[
18331811
"System",

test/Sentry.Testing/FakeFileSystem.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ internal class FakeFileSystem : IFileSystem
66
{
77
private readonly MockFileSystem _fileSystem = new();
88

9+
public IEnumerable<string> EnumerateDirectories(string path, string searchPattern) =>
10+
_fileSystem.Directory.EnumerateDirectories(path, searchPattern);
11+
912
public IEnumerable<string> EnumerateFiles(string path) => _fileSystem.Directory.EnumerateFiles(path);
1013

1114
public IEnumerable<string> EnumerateFiles(string path, string searchPattern) =>

test/Sentry.Tests/SentrySdkTests.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,8 @@ public async Task Init_WithCache_BlocksUntilExistingCacheIsFlushed(bool? testDel
268268

269269
// Force all instances to use the same cache path
270270
var initCounter = Substitute.For<IInitCounter>();
271-
initCounter.Count.Returns(0);
272-
Func<int?> processIdResolver = () => 1;
271+
initCounter.Count.Returns(1);
272+
Func<int?> processIdResolver = () => 42;
273273

274274
// Pre-populate cache
275275
var initialInnerTransport = Substitute.For<ITransport>();
@@ -369,8 +369,10 @@ public async Task Init_WithCache_BlocksUntilExistingCacheIsFlushed(bool? testDel
369369
finally
370370
{
371371
// cleanup to avoid disposing/deleting the temp directory while the cache worker is still running
372-
var cachingTransport = (CachingTransport)options!.Transport;
373-
await cachingTransport!.StopWorkerAsync();
372+
if (options!.Transport is CachingTransport cachingTransport)
373+
{
374+
await cachingTransport!.StopWorkerAsync();
375+
}
374376
}
375377
}
376378
#endif

0 commit comments

Comments
 (0)