diff --git a/docs/learn/dev-file.html b/docs/learn/dev-file.html index a9b68b20..f86f49b5 100644 --- a/docs/learn/dev-file.html +++ b/docs/learn/dev-file.html @@ -62,10 +62,11 @@
.well-known/ directory are not served.
TryAddRef /
- Release). The live reference counts as 230 and each lease as 1, so lease
- releases alone, even a lease disposed twice, never take the live snapshot to zero. It is actually
- freed - descriptors closed - only once a reload or Dispose has dropped the live
- reference and the last lease is gone. This is what makes reloads safe (below).Release). The live reference counts as 248 and each lease as 1, so lease
+ releases alone cannot take the live snapshot to zero: even a lease disposed twice on every request
+ would take years at a million requests a second. With each lease disposed once, it is freed -
+ descriptors closed - only once a reload or Dispose has dropped the live reference and
+ the last lease is gone. This is what makes reloads safe (below).
AssetCache.cs · building the snapshot - one descriptor, path and length per file, no bytes read
public readonly record struct Asset(int Fd, string Path, long Length);
@@ -105,12 +106,12 @@ AssetCache · AssetCache.cs
AttributesToSkip = FileAttributes.ReparsePoint | FileAttributes.Hidden | FileAttributes.System,
};
AssetCache.cs · a lock-free refcount - the snapshot frees only when the last hold drops
-private const int LiveRef = 1 << 30; // lease releases alone never reach 0
-private int _refs = LiveRef; // plus one per lease
+private const long LiveRef = 1L << 48; // extra releases or leaked leases need years at 1M/s to drain or overflow it
+private long _refs = LiveRef; // plus one per lease
internal bool TryAddRef()
{
- int r;
+ long r;
do
{
r = Volatile.Read(ref _refs);
@@ -139,28 +140,32 @@ AssetCache · AssetCache.cs
StaticAssets · StaticAssets.cs
A reloadable holder around an AssetCache. Reload builds a fresh snapshot,
swaps it in atomically (Interlocked.Exchange), and drops the live reference on the old
- one - which is then freed only once every outstanding lease on it is released. No timer, no grace
- window: an in-flight read on the old snapshot can never hit a closed fd.
+ one. With each lease disposed once, the old snapshot is freed only when the last lease on it is
+ released, so with no timer and no grace window, an in-flight read on it never hits a closed fd.
+ Dispose drops the live snapshot the same way, under the same lock as a reload, and is
+ idempotent; after it Acquire throws ObjectDisposedException and
+ Reload does nothing, so a reload signalled during shutdown cannot bring the assets back.
- Acquire / Lease -
Acquire() returns a Lease (a refcounted
hold) on the live snapshot; the handler holds it for the whole request - lookup → ring read →
send - and disposes it when the response is fully sent. TryAddRef fails if the
snapshot is already releasing, so Acquire simply re-reads the live one. A reload
swaps the live snapshot before it releases the old one, so a refusal from the snapshot that is
- still live means Dispose freed it, and Acquire throws
- ObjectDisposedException instead of retrying.
+ still live means extra lease releases drained it, and Acquire throws
+ ObjectDisposedException instead of retrying, as it does once the assets are disposed.
StaticAssets.cs · Acquire leases the live snapshot; Reload swaps a fresh one in atomically
public Lease Acquire()
{
while (true)
{
- AssetCache cache = Volatile.Read(ref _cache);
+ AssetCache? cache = Volatile.Read(ref _cache);
+ ObjectDisposedException.ThrowIf(cache is null, this); // disposed
if (cache.TryAddRef())
{
return new Lease(cache);
}
- // refused: swapped out by a reload (re-read the live one), or freed by Dispose if still live
+ // refused: swapped out by a reload (re-read the live one), or freed under it if still live
ObjectDisposedException.ThrowIf(cache == Volatile.Read(ref _cache), this);
}
}
@@ -169,6 +174,7 @@ StaticAssets · StaticAssets.cs
{
lock (_reloadLock)
{
+ if (_cache is null) return; // disposed: nothing to reload
AssetCache fresh;
try { fresh = new AssetCache(_root); }
catch (Exception e)
@@ -176,9 +182,17 @@ StaticAssets · StaticAssets.cs
Console.Error.WriteLine($"[ioxide] asset reload failed, keeping current snapshot: {e.Message}");
return;
}
- AssetCache old = Interlocked.Exchange(ref _cache, fresh);
+ AssetCache old = Interlocked.Exchange(ref _cache, fresh)!;
old.Dispose(); // drops the live ref; old frees once every outstanding lease releases
}
+}
+
+public void Dispose()
+{
+ lock (_reloadLock)
+ {
+ Interlocked.Exchange(ref _cache, null)?.Dispose(); // idempotent; a reload in progress finishes first
+ }
}
AssetReader · AssetReader.cs
diff --git a/docs/learn/files.html b/docs/learn/files.html
index 3728a779..01dd89a1 100644
--- a/docs/learn/files.html
+++ b/docs/learn/files.html
@@ -99,7 +99,8 @@ Atomic reloads
Snapshots are immutable; deploys swap them whole. StaticAssets.Reload()
builds a fresh AssetCache, swaps it in atomically, and drops its reference to
the old one, whose descriptors close when the last lease on it is disposed - a request always
- sees one whole snapshot, and a read in flight never meets a closed descriptor. If the rebuild
+ sees one whole snapshot, and with each lease disposed once, a read in flight never meets a closed
+ descriptor. If the rebuild
fails, the current snapshot stays. Wire it to SIGHUP and a deploy is
kill -HUP $(pidof server):
PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
diff --git a/src/clients/ioxide.file/AssetCache.cs b/src/clients/ioxide.file/AssetCache.cs
index 8ccf5b2a..aed165e8 100644
--- a/src/clients/ioxide.file/AssetCache.cs
+++ b/src/clients/ioxide.file/AssetCache.cs
@@ -19,9 +19,8 @@ public sealed class AssetCache : IDisposable
private readonly Dictionary _assets;
private readonly SafeFileHandle[] _handles;
- // The live reference counts as 2^30, so lease releases alone, even a double one, never reach 0.
- private const int LiveRef = 1 << 30;
- private int _refs = LiveRef; // plus one per lease
+ private const long LiveRef = 1L << 48; // extra releases or leaked leases need years at 1M/s to drain or overflow it
+ private long _refs = LiveRef; // plus one per lease
/// The absolute root directory the cache was built over.
public string RootDir { get; }
@@ -97,13 +96,13 @@ public bool TryGet(ReadOnlySpan urlPath, out Asset asset)
///
internal bool TryAddRef()
{
- int r;
+ long r;
do
{
r = Volatile.Read(ref _refs);
if (r <= 0)
{
- return false; // freed, or below zero after a second Dispose - don't resurrect
+ return false; // freed, or below zero after extra lease releases - don't resurrect
}
}
while (Interlocked.CompareExchange(ref _refs, r + 1, r) != r);
diff --git a/src/clients/ioxide.file/StaticAssets.cs b/src/clients/ioxide.file/StaticAssets.cs
index fb074873..20e0efc3 100644
--- a/src/clients/ioxide.file/StaticAssets.cs
+++ b/src/clients/ioxide.file/StaticAssets.cs
@@ -7,15 +7,17 @@ namespace ioxide.file;
///
///
/// To serve safely across a reload, take a with and hold
-/// it for the whole request (lookup → ring read → send): the old snapshot's descriptors are freed
-/// only once every lease on it is released, so an in-flight read can never hit a closed fd.
+/// it for the whole request (lookup → ring read → send), and dispose it once: the old snapshot's
+/// descriptors are freed only once every lease on it is released, so an in-flight read never hits a
+/// closed fd. After , , and
+/// throw and does nothing; leases already out keep the last snapshot open until they are released.
///
public sealed class StaticAssets : IDisposable
{
private readonly string _root;
private readonly object _reloadLock = new();
- private AssetCache _cache; // swapped atomically by Reload(), read with Volatile
+ private AssetCache? _cache; // swapped atomically by Reload(), read with Volatile; null once disposed
public StaticAssets(string rootDir)
{
@@ -23,41 +25,49 @@ public StaticAssets(string rootDir)
_cache = new AssetCache(rootDir);
}
- public int Count => Volatile.Read(ref _cache).Count;
+ public int Count => Live.Count;
- public string RootDir => Volatile.Read(ref _cache).RootDir;
+ public string RootDir => Live.RootDir;
+
+ private AssetCache Live => Volatile.Read(ref _cache) ?? throw new ObjectDisposedException(nameof(StaticAssets));
///
/// Lease the live snapshot for the duration of a request. The snapshot it points at stays alive
- /// (descriptors open) until the lease is disposed, even across a concurrent .
- /// Dispose it when the response has been fully sent.
+ /// (descriptors open) until the lease is disposed, even across a concurrent ,
+ /// as long as every lease is disposed once. Dispose it when the response has been fully sent.
///
- /// This was disposed and its snapshot freed.
+ /// This was disposed.
public Lease Acquire()
{
while (true)
{
- AssetCache cache = Volatile.Read(ref _cache);
+ AssetCache? cache = Volatile.Read(ref _cache);
+ ObjectDisposedException.ThrowIf(cache is null, this);
if (cache.TryAddRef())
{
return new Lease(cache);
}
- // Refused. A Reload swaps the live snapshot before releasing the old one, so a refusal from the
- // one still live means Dispose freed it and nothing will replace it; otherwise re-read the live one.
+ // A Reload swaps before it releases, so only the snapshot still live can refuse for good.
ObjectDisposedException.ThrowIf(cache == Volatile.Read(ref _cache), this);
}
}
///
/// Rebuild the snapshot from disk and swap it in atomically, then release the old one (it frees
- /// once all leases on it are dropped). If the rebuild fails, the current snapshot is kept.
- /// Thread-safe.
+ /// once all leases on it are dropped). If the rebuild fails, the current snapshot is kept. After
+ /// it does nothing, so a reload signalled during shutdown cannot bring the
+ /// assets back. Thread-safe.
///
public void Reload()
{
lock (_reloadLock)
{
+ if (_cache is null)
+ {
+ return;
+ }
+
AssetCache fresh;
try
{
@@ -69,12 +79,22 @@ public void Reload()
return;
}
- AssetCache old = Interlocked.Exchange(ref _cache, fresh);
+ AssetCache old = Interlocked.Exchange(ref _cache, fresh)!;
old.Dispose(); // drops the live reference; old frees when outstanding leases release
}
}
- public void Dispose() => Volatile.Read(ref _cache).Dispose();
+ ///
+ /// Drop the live snapshot, which frees once the leases on it are released. Idempotent; under the reload lock,
+ /// so a reload in progress finishes first and its snapshot is the one dropped.
+ ///
+ public void Dispose()
+ {
+ lock (_reloadLock)
+ {
+ Interlocked.Exchange(ref _cache, null)?.Dispose();
+ }
+ }
///
/// A reference-counted hold on an snapshot. Look up assets through it
diff --git a/tests/Ioxide.Tests.File/LeaseTests.cs b/tests/Ioxide.Tests.File/LeaseTests.cs
index 35c04229..cd94be1d 100644
--- a/tests/Ioxide.Tests.File/LeaseTests.cs
+++ b/tests/Ioxide.Tests.File/LeaseTests.cs
@@ -42,8 +42,7 @@ public static void Register(Runner runner)
Assert.True(found, "the lease Acquire returned should find /a.txt");
});
- // Dispose frees the live snapshot and nothing replaces it, so a retry that waits for a Reload's swap
- // would re-read the freed snapshot forever. A second Dispose takes its count below zero.
+ // After Dispose there is no snapshot left to lease, and a second Dispose changes nothing.
runner.Test("file: Acquire after Dispose throws ObjectDisposedException, also after a second Dispose", () =>
{
var assets = new StaticAssets(LeaseAssets());
@@ -87,6 +86,138 @@ public static void Register(Runner runner)
$"a reload with no lease out kept the old snapshot's fd {unleased.Fd} open");
assets.Dispose();
});
+
+ // A reload signalled during shutdown runs after Dispose, with a request's lease still out: it must revive nothing.
+ runner.Test("file: after Dispose, Reload does nothing and Acquire throws, and a lease still out keeps its snapshot", () =>
+ {
+ var assets = new StaticAssets(LeaseAssets());
+ StaticAssets.Lease held = assets.Acquire();
+ Assert.True(held.TryGet("/a.txt", out AssetCache.Asset asset), "the snapshot should hold /a.txt");
+
+ assets.Dispose();
+ assets.Reload();
+ Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets));
+ Assert.Throws(() => _ = assets.Count, nameof(StaticAssets));
+ Assert.Throws(() => _ = assets.RootDir, nameof(StaticAssets));
+ Assert.Equal(asset.Path, ExecInheritance.Target(asset.Fd)); // the lease still reads its file
+
+ held.Dispose();
+ Assert.True(ExecInheritance.Target(asset.Fd) != asset.Path,
+ $"the snapshot kept fd {asset.Fd} open after its last lease was released");
+ });
+
+ // Only the first Dispose may drop the live reference, or the lease held across the rest never frees its snapshot.
+ runner.Test("file: Dispose can be called any number of times", () =>
+ {
+ string root = LeaseAssets();
+ for (int times = 1; times <= 6; times++)
+ {
+ var assets = new StaticAssets(root);
+ StaticAssets.Lease held = assets.Acquire();
+ Assert.True(held.TryGet("/a.txt", out AssetCache.Asset asset), "the snapshot should hold /a.txt");
+ for (int i = 0; i < times; i++)
+ {
+ assets.Dispose();
+ }
+ Assert.Equal(asset.Path, ExecInheritance.Target(asset.Fd)); // the lease still reads its file
+
+ held.Dispose();
+ Assert.True(ExecInheritance.Target(asset.Fd) != asset.Path,
+ $"after {times} Dispose calls the snapshot kept fd {asset.Fd} open past its last lease");
+ Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets));
+ }
+ });
+
+ // They share a lock, so a reload in progress finishes first and its snapshot is the one dropped.
+ runner.Test("file: Dispose racing Reload leaves no descriptor open and nothing to lease", () =>
+ {
+ string root = LeaseAssets();
+ string file = new FileInfo(Path.Combine(root, "a.txt")).FullName;
+ for (int round = 0; round < 300; round++)
+ {
+ var assets = new StaticAssets(root);
+ int reloads = 0;
+ bool stop = false;
+ Exception? thrown = null;
+ var reloader = new Thread(() =>
+ {
+ try
+ {
+ while (!Volatile.Read(ref stop))
+ {
+ assets.Reload();
+ Interlocked.Increment(ref reloads);
+ }
+ }
+ catch (Exception e)
+ {
+ thrown = e;
+ }
+ }) { IsBackground = true };
+ reloader.Start();
+
+ while (Volatile.Read(ref reloads) == 0 && reloader.IsAlive)
+ {
+ Thread.SpinWait(16); // so the Dispose lands on a reload under way
+ }
+ assets.Dispose();
+ Volatile.Write(ref stop, true);
+ Assert.True(reloader.Join(TimeSpan.FromSeconds(5)), "a Reload racing Dispose never returned");
+
+ Assert.True(thrown is null, $"Reload threw {thrown?.GetType().Name} in round {round}");
+ Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets));
+ HashSet open = ExecInheritance.Referring(file);
+ Assert.True(open.Count == 0, $"round {round} left fds {string.Join(", ", open)} open on the file");
+ }
+ });
+
+ // A snapshot a concurrent Reload swapped out refuses the lease, and the next read finds the new one: no exception, no failed lookup.
+ runner.Test("file: Acquire racing Reload always gets a lease on a whole snapshot", () =>
+ {
+ var assets = new StaticAssets(LeaseAssets());
+ int failures = 0;
+ long leases = 0;
+ bool stop = false;
+
+ var acquirers = new Thread[3];
+ for (int t = 0; t < acquirers.Length; t++)
+ {
+ acquirers[t] = new Thread(() =>
+ {
+ while (!Volatile.Read(ref stop))
+ {
+ try
+ {
+ using StaticAssets.Lease lease = assets.Acquire();
+ if (!lease.TryGet("/a.txt", out _))
+ {
+ Interlocked.Increment(ref failures);
+ }
+ Interlocked.Increment(ref leases);
+ }
+ catch
+ {
+ Interlocked.Increment(ref failures);
+ }
+ }
+ }) { IsBackground = true };
+ acquirers[t].Start();
+ }
+
+ for (int i = 0; i < 5_000; i++)
+ {
+ assets.Reload();
+ }
+ Volatile.Write(ref stop, true);
+ foreach (Thread acquirer in acquirers)
+ {
+ Assert.True(acquirer.Join(TimeSpan.FromSeconds(5)), "an Acquire racing Reload never returned");
+ }
+ assets.Dispose();
+
+ Assert.True(leases > 0, "no lease was taken while reloading");
+ Assert.Equal(0, failures);
+ });
}
// Acquire on its own thread, so a spin fails the test instead of hanging the run: whether it returned or
@@ -112,7 +243,7 @@ private static (bool Returned, Exception? Thrown, bool Found) AcquireOnThread(St
bool returned = acquirer.Join(TimeSpan.FromSeconds(2));
if (!returned)
{
- assets.Reload(); // the one thing that ends the spin, so the thread doesn't keep a core for the rest of the run
+ assets.Reload(); // ends a spin on a live instance, so the thread doesn't keep a core for the rest of the run
acquirer.Join(TimeSpan.FromSeconds(2));
}