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
2 changes: 1 addition & 1 deletion src/main/Hangfire.Storage.SQLite/ExpirationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ private int RemoveExpireRows(HangfireDbContext db, string table)
try
{
using (SQLiteDistributedLock.Acquire(DistributedLockKey, DefaultLockTimeout,
db, db.StorageOptions))
db, db.StorageOptions, _storage))
{
rowsAffected = db.Database.Execute(deleteScript);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<Version>0.5.0-beta</Version>
<Version>0.5.1-beta</Version>
<PackageId>tbbuck.Hangfire.Storage.SQLite</PackageId>
<Authors>Thomas Buck</Authors>
<Company>Thomas Buck</Company>
Expand All @@ -24,6 +24,11 @@
<title>Hangfire Storage SQLite (tbbuck fork)</title>
<Description>An alternative SQLite storage for Hangfire. Maintained fork of RaisedApp/Hangfire.Storage.SQLite that modernises the SQLite dependency stack and remediates CVE-2025-6965.</Description>
<PackageReleaseNotes>
0.5.1-beta (tbbuck fork)
- Fix AccessViolationException / storage corruption in SQLiteDistributedLock: the lock
heartbeat now runs on its own dedicated connection instead of sharing the caller's
non-thread-safe (NoMutex) connection (upstream issue #79).

0.5.0-beta (tbbuck fork)
- Security: remediate CVE-2025-6965 (SQLite &lt; 3.50.2, High/CVSS 7.2) by dropping the
bundled SQLitePCLRaw.lib.e_sqlite3 2.1.11 native library.
Expand Down
8 changes: 7 additions & 1 deletion src/main/Hangfire.Storage.SQLite/HangfireSQLiteConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ public class HangfireSQLiteConnection : JobStorageConnection

public HangfireDbContext DbContext { get; }

/// <summary>
/// Owning storage, set by <see cref="SQLiteStorage.GetConnection"/>. Lets the distributed
/// lock heartbeat run on a dedicated connection instead of this connection (issue #79).
/// </summary>
internal SQLiteStorage Storage { get; set; }

/// <summary>
/// Ctor using default storage options
/// </summary>
Expand Down Expand Up @@ -47,7 +53,7 @@ public override void Dispose()
public override IDisposable AcquireDistributedLock(string resource, TimeSpan timeout)
{
return Retry.Twice((_) =>
SQLiteDistributedLock.Acquire($"HangFire:{resource}", timeout, DbContext, _storageOptions)
SQLiteDistributedLock.Acquire($"HangFire:{resource}", timeout, DbContext, _storageOptions, Storage)
);
}

Expand Down
44 changes: 41 additions & 3 deletions src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public class SQLiteDistributedLock : IDisposable

private readonly SQLiteStorageOptions _storageOptions;

// When available, the heartbeat uses its own dedicated connection from the storage pool
// instead of the consumer's (NoMutex, not thread-safe) connection. See issue #79.
private readonly SQLiteStorage _storage;

private Timer _heartbeatTimer;

private bool _completed;
Expand All @@ -38,11 +42,13 @@ public class SQLiteDistributedLock : IDisposable
/// <exception cref="DistributedLockTimeoutException">Thrown if lock is not acuired within the timeout</exception>
private SQLiteDistributedLock(string resource,
HangfireDbContext database,
SQLiteStorageOptions storageOptions)
SQLiteStorageOptions storageOptions,
SQLiteStorage storage)
{
_resource = resource ?? throw new ArgumentNullException(nameof(resource));
_dbContext = database ?? throw new ArgumentNullException(nameof(database));
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
_storage = storage;
_resourceKey = Guid.NewGuid().ToString();

if (string.IsNullOrEmpty(resource))
Expand All @@ -56,13 +62,28 @@ public static SQLiteDistributedLock Acquire(
TimeSpan timeout,
HangfireDbContext database,
SQLiteStorageOptions storageOptions)
{
return Acquire(resource, timeout, database, storageOptions, null);
}

/// <summary>
/// Creates SQLite distributed lock, using a dedicated connection from <paramref name="storage"/>
/// for the heartbeat so that the timer thread never shares the caller's (non-thread-safe)
/// connection. See issue #79.
/// </summary>
internal static SQLiteDistributedLock Acquire(
string resource,
TimeSpan timeout,
HangfireDbContext database,
SQLiteStorageOptions storageOptions,
SQLiteStorage storage)
{
if (timeout.TotalSeconds > int.MaxValue)
{
throw new ArgumentException($"The timeout specified is too large. Please supply a timeout equal to or less than {int.MaxValue} seconds", nameof(timeout));
}

var slock = new SQLiteDistributedLock(resource, database, storageOptions);
var slock = new SQLiteDistributedLock(resource, database, storageOptions, storage);

slock.Acquire(timeout);
slock.StartHeartBeat();
Expand Down Expand Up @@ -179,7 +200,24 @@ private void StartHeartBeat()
// but since we use the resource key, we will not disturb other owners.
try
{
var didUpdate = UpdateExpiration(_dbContext.DistributedLockRepository, DateTime.UtcNow.Add(_storageOptions.DistributedLockLifetime));
var newExpiry = DateTime.UtcNow.Add(_storageOptions.DistributedLockLifetime);

bool didUpdate;
if (_storage != null)
{
// Run the heartbeat on a dedicated connection so the timer thread never
// touches the consumer's connection concurrently (issue #79).
using (var heartbeatContext = _storage.CreateAndOpenConnection())
{
didUpdate = UpdateExpiration(heartbeatContext.DistributedLockRepository, newExpiry);
}
}
else
{
// Legacy path (no storage available): falls back to the shared connection.
didUpdate = UpdateExpiration(_dbContext.DistributedLockRepository, newExpiry);
}

Heartbeat?.Invoke(didUpdate);
if (!didUpdate)
{
Expand Down
2 changes: 1 addition & 1 deletion src/main/Hangfire.Storage.SQLite/SQLiteStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public override IStorageConnection GetConnection()
{
CheckDisposed();
var dbContext = CreateAndOpenConnection();
return new HangfireSQLiteConnection(dbContext, _storageOptions, QueueProviders);
return new HangfireSQLiteConnection(dbContext, _storageOptions, QueueProviders) { Storage = this };
}

public override IMonitoringApi GetMonitoringApi()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Hangfire.Storage.SQLite.Entities;
using Hangfire.Storage.SQLite.Test.Utils;
using System;
using System.IO;
using System.Linq;
using System.Threading;
using Xunit;

namespace Hangfire.Storage.SQLite.Test
{
public class SQLiteDistributedLockHeartbeatFacts
{
[Fact]
public void Heartbeat_RenewsExpiration_OnADedicatedConnection()
{
var options = new SQLiteStorageOptions { DistributedLockLifetime = TimeSpan.FromSeconds(1) }; // beat ~200ms
var storage = ConnectionUtils.CreateStorage(options);
using var consumer = storage.CreateAndOpenConnection();

DateTime ReadExpireAt()
{
using var reader = storage.CreateAndOpenConnection();
return reader.DistributedLockRepository.First(x => x.Resource == "res-hb").ExpireAt;
}

using (SQLiteDistributedLock.Acquire("res-hb", TimeSpan.FromSeconds(5), consumer, options, storage))
{
var before = ReadExpireAt();
Thread.Sleep(700); // allow a few heartbeats to fire
var after = ReadExpireAt();

Assert.True(after > before, $"Heartbeat should have renewed ExpireAt; before={before:O} after={after:O}");
}

// After dispose, the lock row is released.
using var check = storage.CreateAndOpenConnection();
Assert.Empty(check.DistributedLockRepository.Where(x => x.Resource == "res-hb").ToList());
}

[Fact]
public void Lock_Heartbeat_DoesNotShareConsumerConnection_UnderConcurrentUse()
{
// Regression for issue #79: the heartbeat timer must run on its own connection so it never
// races the consumer's (NoMutex, non-thread-safe) connection. Uses a file-backed DB (WAL)
// so genuine concurrent writes from both connections are exercised.
var dbPath = Path.Combine(Path.GetTempPath(), $"hf_lock_{Guid.NewGuid():n}.db");
try
{
var options = new SQLiteStorageOptions { DistributedLockLifetime = TimeSpan.FromSeconds(1) };
var storage = new SQLiteStorage(dbPath, options);

using (var consumer = storage.CreateAndOpenConnection())
using (SQLiteDistributedLock.Acquire("res-stress", TimeSpan.FromSeconds(5), consumer, options, storage))
{
// Hammer the consumer connection while the heartbeat fires concurrently.
var deadline = DateTime.UtcNow.AddSeconds(2);
var n = 0;
while (DateTime.UtcNow < deadline)
{
consumer.Database.Insert(new JobParameter
{
JobId = 1,
Name = $"p{n++}",
Value = "v",
ExpireAt = DateTime.UtcNow.AddMinutes(5),
});
}

Assert.True(n > 0);
}

storage.Dispose();
}
finally
{
foreach (var f in new[] { dbPath, dbPath + "-wal", dbPath + "-shm" })
{
try { File.Delete(f); } catch { /* best effort */ }
}
}
}
}
}
Loading