From 62a62815bf5493043b5973f334aed3ccbd87027c Mon Sep 17 00:00:00 2001
From: Diogo Martins AssetCache.cs · building the snapshot - one descriptor, path and length per file, no bytes read AssetCache.cs · a lock-free refcount - the snapshot frees only when the last hold drops A reloadable holder around an AssetCache · AssetCache.cs
skipped too, and on Linux that is any name starting with a dot, so dot-files and a
.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).
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,
};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; // lease releases alone cannot reach 0
+private long _refs = LiveRef; // plus one per lease
internal bool TryAddRef()
{
- int r;
+ long r;
do
{
r = Volatile.Read(ref _refs);
@@ -140,27 +141,31 @@ StaticAssets · StaticAssets.cs
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.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() 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.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
+ }
}
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 3d4cc6fb..54e46687 100644
--- a/src/clients/ioxide.file/AssetCache.cs
+++ b/src/clients/ioxide.file/AssetCache.cs
@@ -19,9 +19,7 @@ public sealed class AssetCache : IDisposable
private readonly Dictionary _assets;
private readonly SafeFileHandle[] _handles;
- // The live reference counts as 2^48, so lease releases alone, even one disposed twice on every request, cannot
- // reach 0 in any real lifetime (2^48 of them: years at a million a second), and leaks cannot overflow it.
- private const long LiveRef = 1L << 48;
+ 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.
diff --git a/src/clients/ioxide.file/StaticAssets.cs b/src/clients/ioxide.file/StaticAssets.cs
index 899bf81c..a53f6966 100644
--- a/src/clients/ioxide.file/StaticAssets.cs
+++ b/src/clients/ioxide.file/StaticAssets.cs
@@ -33,8 +33,8 @@ public StaticAssets(string rootDir)
///
/// 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.
public Lease Acquire()
@@ -48,9 +48,7 @@ public Lease Acquire()
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 it was freed under us (2^48 releases too many) 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);
}
}
diff --git a/tests/Ioxide.Tests.File/LeaseTests.cs b/tests/Ioxide.Tests.File/LeaseTests.cs
index f0004d17..bf9e60a7 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());
@@ -106,18 +105,24 @@ public static void Register(Runner runner)
$"the snapshot kept fd {asset.Fd} open after its last lease was released");
});
- // Each Dispose used to subtract the live reference again: a 32-bit count wrapped on the fourth back to a
- // live-looking value, and Acquire handed out leases on the freed snapshot.
+ // 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));
}
});
@@ -195,7 +200,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));
}
From fe172f65da7b8e767de3f3169596729bfc44a324 Mon Sep 17 00:00:00 2001
From: Diogo Martins
Date: Thu, 5 Nov 2026 20:52:18 +0000
Subject: [PATCH 3/3] file: a test races Dispose against Reload and checks
Count and RootDir throw, and the comments and docs say what the count can do
A double disposed lease is the case that drains the live reference, not a second Dispose of the assets, which Dispose no longer repeats.
---
docs/learn/dev-file.html | 8 ++--
src/clients/ioxide.file/AssetCache.cs | 2 +-
src/clients/ioxide.file/StaticAssets.cs | 4 +-
tests/Ioxide.Tests.File/LeaseTests.cs | 53 ++++++++++++++++++++++---
4 files changed, 55 insertions(+), 12 deletions(-)
diff --git a/docs/learn/dev-file.html b/docs/learn/dev-file.html
index f80c3066..f86f49b5 100644
--- a/docs/learn/dev-file.html
+++ b/docs/learn/dev-file.html
@@ -106,7 +106,7 @@ 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 long LiveRef = 1L << 48; // lease releases alone cannot reach 0
+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()
@@ -140,8 +140,8 @@ 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: with each lease disposed once, an in-flight read on the old snapshot never hits 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.
@@ -151,7 +151,7 @@ StaticAssets · StaticAssets.cs
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 it was freed under it, and Acquire throws
+ 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
diff --git a/src/clients/ioxide.file/AssetCache.cs b/src/clients/ioxide.file/AssetCache.cs
index 54e46687..aed165e8 100644
--- a/src/clients/ioxide.file/AssetCache.cs
+++ b/src/clients/ioxide.file/AssetCache.cs
@@ -102,7 +102,7 @@ internal bool TryAddRef()
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 a53f6966..20e0efc3 100644
--- a/src/clients/ioxide.file/StaticAssets.cs
+++ b/src/clients/ioxide.file/StaticAssets.cs
@@ -9,8 +9,8 @@ namespace ioxide.file;
/// To serve safely across a reload, take a with and hold
/// 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 , throws and does
-/// nothing; leases already out keep the last snapshot open until they are released.
+/// 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
{
diff --git a/tests/Ioxide.Tests.File/LeaseTests.cs b/tests/Ioxide.Tests.File/LeaseTests.cs
index bf9e60a7..cd94be1d 100644
--- a/tests/Ioxide.Tests.File/LeaseTests.cs
+++ b/tests/Ioxide.Tests.File/LeaseTests.cs
@@ -87,8 +87,7 @@ public static void Register(Runner runner)
assets.Dispose();
});
- // A reload signalled during shutdown (SIGHUP, say) runs after Dispose, with a request's lease still out. It
- // must not bring the assets back, and the snapshot that lease holds must still close when it is released.
+ // 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());
@@ -97,7 +96,9 @@ public static void Register(Runner runner)
assets.Dispose();
assets.Reload();
- Assert.Throws(() => assets.Acquire().Dispose(), nameof(StaticAssets)); // after Dispose and a 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();
@@ -127,8 +128,50 @@ public static void Register(Runner runner)
}
});
- // Acquire's retry is for exactly this: a snapshot that a concurrent Reload swapped out refuses the lease,
- // and the next read finds the new one. None of that may surface as an exception or a failed lookup.
+ // 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());