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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ them until tagged releases begin.
## [Unreleased]

### Added
- **A waiting tab now finds out when the database becomes free.** `BrowserSqliteOwnership.Available`
completes in a non-owner tab once the owning tab closes, so an app can turn "close the other tab" into
"your data is ready — reload" instead of leaving the user to guess when the condition was met.
**Reloading is what takes ownership**, deliberately: a waiting tab already opened its own empty database
at boot, so the file cannot be swapped under its live connections, and a tab that started persisting its
empty database would overwrite the previous owner's good snapshot with nothing. The watcher polls with
`TryRequestAsync`, which acquires and releases within the call, rather than waiting on `RequestAsync` —
waiting would mean *holding* the lock the moment it frees, which would both make this tab an owner it
must not be and block a tab that could actually use it. The signal is therefore advisory: another tab
may win between the poll and the reload, and the reloaded page runs the normal election to find out.
Tunable via `TakeoverPollInterval` (2s default); `samples/Rask.Example.Wasm.Jobs` shows it, covered by
an E2E that opens two real tabs and closes the owner.
- **A browser database now asks not to be evicted.** `Rask.SQLite.Browser` keeps its snapshots in
IndexedDB, and IndexedDB is evictable: under storage pressure a browser may discard them, and the
database comes back empty on the next load with nothing to indicate why. The owning tab now calls
`navigator.storage.persist()` at startup (via `IStorageEstimator`, added in #645), checking
`IsPersistedAsync()` first so an already-exempt origin is never asked twice. A refusal is logged and
changes nothing else — the app runs exactly as before, the risk is just no longer silent. Only the
owning tab asks, since the others persist nothing. Chromium decides from engagement heuristics without
prompting; **Firefox prompts**, and this is asked during boot rather than from a click, so an app that
would rather choose its moment sets `o.RequestPersistentStorage = false` and calls
`IStorageEstimator.RequestPersistAsync()` from a user-gesture handler instead.
- **`BrowserSqliteOwnership` — let a second tab explain itself.** Only one tab may own a browser SQLite
database, so the others run against their own empty, unpersisted one. That is correct, and until now it
was also indistinguishable from the user's data having been deleted: the package logged a warning to the
Expand Down
13 changes: 12 additions & 1 deletion docs/sqlite.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,13 @@ Three limits, stated plainly because each one is a silent failure rather than an
for a `pagehide` handler, so a force-closed or crashed tab loses whatever changed since the last tick.
Shorten the interval if that matters; each tick copies the whole database, so the cost scales with its
size rather than with how much changed.
- **Snapshots live in IndexedDB, and IndexedDB is evictable.** Under storage pressure a browser may
discard them, and the database would come back empty on the next load with nothing to indicate why. The
owning tab therefore asks for the origin to be exempted (`navigator.storage.persist()`) at startup, and
logs a refusal rather than failing. Chromium decides from engagement heuristics without prompting;
**Firefox prompts**, and this is asked during boot rather than from a click — so an app that would
rather pick its moment sets `o.RequestPersistentStorage = false` and calls
`IStorageEstimator.RequestPersistAsync()` from a user-gesture handler instead.
- **One tab owns the database.** Every tab has its own copy of the in-memory filesystem, so two owners
would mean two divergent databases and a last-writer-wins overwrite. The others run with their own empty,
unpersisted database — which, left unexplained, looks exactly like the user's data having been deleted.
Expand All @@ -564,7 +571,11 @@ Three limits, stated plainly because each one is a silent failure rather than an
// "not the owner" stay distinguishable and the banner never flashes during a normal boot.
```

Promoting a waiting tab when the owner closes, and proxying its writes to the owner, are not implemented.
When the owner closes, `await ownership.Available` completes in the waiting tab so you can offer a
reload. **Reloading is what takes it over** — a waiting tab already opened its own empty database at
boot, so the file cannot be swapped under its live connections, and a tab that started persisting its
empty database would overwrite the previous owner's good snapshot. Proxying a non-owner's writes to the
owner is not implemented.
- **The two build settings above are not optional**: `PublishTrimmed=false`, and publishing *without*
`-p:WasmBuildNative=false` — otherwise SQLite is not linked in and the app boots normally, then fails on
every database call.
Expand Down
6 changes: 6 additions & 0 deletions samples/Rask.Example.Wasm.Jobs/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public class App : Component
color: #e8d9b0;
font-size: .92rem;
}
/* Green once it is actionable: the same box, but now it is good news. */
.notice.ready {
background: #12251a;
border-color: #1f5c33;
color: #b6e8c6;
}
ul { list-style: none; padding: 0; margin: 1rem 0 0; }
li {
padding: .6rem .8rem;
Expand Down
29 changes: 25 additions & 4 deletions samples/Rask.Example.Wasm.Jobs/JobsDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public sealed class JobsDemo : Component

private string _name = "world";
private string _status = "";
private bool _canTakeOver;
private List<Greeting> _greetings = [];

public JobsDemo(
Expand Down Expand Up @@ -46,6 +47,12 @@ protected override async Task OnMountAsync()
// for it, or the banner would never appear in the tab that needs it.
await _ownership.Resolved;

if (_ownership.IsOwner == false)
{
// Fire-and-forget: this completes only when the other tab closes, which may be never.
_ = WatchForTakeoverAsync();
}

await _ready.Ready;
await LoadAsync();
}
Expand All @@ -57,6 +64,15 @@ protected override async Task OnMountAsync()
// page silently stale, which looks identical to the job never having run.
private void OnGreetingWritten() => _ = ReloadSafelyAsync();

// Turns "close the other tab" into "reload now". Reloading is the only way to take over: this tab
// already opened its own empty database at boot, and the file cannot be swapped under live connections.
private async Task WatchForTakeoverAsync()
{
await _ownership.Available;
_canTakeOver = true;
StateHasChanged();
}

private async Task ReloadSafelyAsync()
{
try
Expand Down Expand Up @@ -90,10 +106,15 @@ private async Task EnqueueAsync()
// Only once the election has settled: `null` means "still deciding", and showing this during
// a normal boot would be a scary banner for a non-problem.
_ownership.IsOwner == false
? Div(Class: "notice", Data: new Dictionary<string, string?> { ["testid"] = "not-owner" })[
Strong()["Another tab has this database open."],
" Your data is safe — it just isn't reachable from here, because only one tab may own "
+ "the file. Close the other tab and reload."]
? _canTakeOver
? Div(Class: "notice ready", Data: new Dictionary<string, string?> { ["testid"] = "can-take-over" })[
Strong()["Your data is ready."],
" The other tab has closed. Reload to use the database here — reloading is what "
+ "takes it over, because this tab already opened an empty one at boot."]
: Div(Class: "notice", Data: new Dictionary<string, string?> { ["testid"] = "not-owner" })[
Strong()["Another tab has this database open."],
" Your data is safe — it just isn't reachable from here, because only one tab may "
+ "own the file. Close the other tab and this will say so."]
: null,
Div(Class: "row")[
Input(
Expand Down
8 changes: 6 additions & 2 deletions samples/Rask.Example.Wasm.Jobs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,9 @@ shows a banner instead. Note it waits for `ownership.Resolved` before rendering
while the election is in flight, so "still deciding" and "not the owner" stay distinct and a normal boot
never flashes a warning.

What is still not implemented: promoting a waiting tab when the owner closes, and proxying a non-owner's
writes to the owner.
Close the owning tab and the banner turns green: `ownership.Available` completes, and the waiting tab
offers a reload. Reloading is what takes ownership — this tab already opened its own empty database at
boot, so the file cannot be swapped under its live connections, and a tab that started persisting an empty
database would overwrite the previous owner's good snapshot.

What is still not implemented: proxying a non-owner's writes to the owner.
117 changes: 117 additions & 0 deletions src/Rask.SQLite.Browser/BrowserSqliteHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal sealed class BrowserSqliteHost(
BrowserSqliteOptions options,
IWebLocks locks,
IIndexedDb indexedDb,
IStorageEstimator storage,
ISqliteSnapshotter snapshotter,
BrowserSqliteOwnership ownership,
ILogger<BrowserSqliteHost> logger) : IHostedService
Expand All @@ -45,6 +46,10 @@ internal sealed class BrowserSqliteHost(
// StopAsync returns, rather than at some unobservable later moment.
private Task<bool>? _ownerHold;

// Stops the non-owner's availability watcher when the page goes away.
private readonly CancellationTokenSource _shutdown = new();
private Task? _takeoverWatch;

/// <summary>Whether this tab owns the database — i.e. whether it may persist anything.</summary>
public bool IsOwner { get; private set; }

Expand All @@ -65,12 +70,67 @@ public async Task StartAsync(CancellationToken cancellationToken)
"Another tab already owns the browser SQLite database '{Name}'. This tab starts with an empty "
+ "in-memory database and will not persist anything, so two tabs cannot overwrite each other.",
options.Name);

// Not awaited: watching for the owner to go away must not hold up the boot.
_takeoverWatch = WatchForAvailabilityAsync(_shutdown.Token);
return;
}

if (options.RequestPersistentStorage)
{
await EnsurePersistentStorageAsync().ConfigureAwait(false);
}

await RestoreAsync(cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Asks the browser not to evict this origin's storage.
/// </summary>
/// <remarks>
/// The snapshots this package writes live in IndexedDB, which is evictable: under storage pressure
/// a browser may discard them and the database returns empty next load, with nothing to say why.
/// A refusal changes nothing about how the app runs, so this never fails the boot — it only makes
/// the risk visible in the log instead of leaving it silent.
/// <para>
/// Checked before asked, so an origin that is already exempt never triggers a second prompt on
/// the browsers that prompt.
/// </para>
/// </remarks>
private async Task EnsurePersistentStorageAsync()
{
try
{
if (await storage.IsPersistedAsync().ConfigureAwait(false))
{
return;
}

if (await storage.RequestPersistAsync().ConfigureAwait(false))
{
logger.LogInformation(
"Storage for '{Name}' is now exempt from eviction.", options.Name);
return;
}

// One branch, not two: RequestPersistAsync resolves false both when the browser declines and
// when it has no such API, and from here those have exactly the same consequence.
logger.LogWarning(
"The browser did not grant persistent storage, so it may evict the snapshots of '{Name}' "
+ "under storage pressure and the database would come back empty. Chromium grants this on "
+ "engagement; Firefox prompts, so ask from a user gesture with "
+ "IStorageEstimator.RequestPersistAsync() and set BrowserSqliteOptions."
+ nameof(BrowserSqliteOptions.RequestPersistentStorage) + " to false.",
options.Name);
}
#pragma warning disable CA1031 // Durability is best-effort; a failed request must not stop the app booting.
catch (Exception ex)
#pragma warning restore CA1031
{
logger.LogWarning(ex, "Could not ask for persistent storage for '{Name}'.", options.Name);
}
}

/// <inheritdoc />
/// <remarks>
/// Best-effort, and deliberately so: this runs from <c>pagehide</c>, which the browser does not
Expand All @@ -93,6 +153,14 @@ public async Task StopAsync(CancellationToken cancellationToken)
}
}

await _shutdown.CancelAsync().ConfigureAwait(false);

if (_takeoverWatch is not null)
{
// Already swallows its own failures; awaiting only makes the stop orderly.
await _takeoverWatch.ConfigureAwait(false);
}

_release.TrySetResult();

if (_ownerHold is null)
Expand All @@ -112,6 +180,55 @@ public async Task StopAsync(CancellationToken cancellationToken)
}
}

/// <summary>
/// Watches, in a tab that is not the owner, for the database to become free.
/// </summary>
/// <remarks>
/// <para>
/// Polls with <see cref="IWebLocks.TryRequestAsync" />, which acquires and releases within the
/// call, rather than waiting on <c>RequestAsync</c>. Waiting would mean <em>holding</em> the
/// lock the moment it frees — and this tab must not own the database: it opened its own empty
/// one at boot, so persisting from here would overwrite the previous owner's good snapshot with
/// nothing. Holding a lock it must never use would also block a tab that could actually use it.
/// </para>
/// <para>
/// So this only ever <em>reports</em> availability, and the taking is done by a reload. That is
/// also why the signal is advisory: another tab may win between the poll and the reload.
/// </para>
/// </remarks>
private async Task WatchForAvailabilityAsync(CancellationToken cancellationToken)
{
var name = BrowserSqlite.OwnerLockName(options.Name);

try
{
using var timer = new PeriodicTimer(options.TakeoverPollInterval);

while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
// The callback is empty on purpose: acquiring proves the lock is free, and returning
// immediately hands it straight back.
if (await locks.TryRequestAsync(name, static () => Task.CompletedTask).ConfigureAwait(false))
{
logger.LogInformation(
"The tab that owned '{Name}' has gone; reload to use the database here.", options.Name);
ownership.MarkAvailable();
return;
}
}
}
catch (OperationCanceledException)
{
// The page is going away.
}
#pragma warning disable CA1031 // A watcher that dies must not take the app with it; the tab simply stops offering to take over.
catch (Exception ex)
#pragma warning restore CA1031
{
logger.LogWarning(ex, "Gave up watching for '{Name}' to become available.", options.Name);
}
}

/// <summary>
/// Takes the owner lock and holds it for the lifetime of the page.
/// </summary>
Expand Down
37 changes: 37 additions & 0 deletions src/Rask.SQLite.Browser/BrowserSqliteOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@ public sealed class BrowserSqliteOptions
/// </summary>
public int Retain { get; set; } = 2;

/// <summary>
/// How often a tab that is not the owner checks whether the database has become free, so it can
/// tell the user to reload. Defaults to 2 seconds.
/// </summary>
/// <remarks>
/// Each check is one Web Locks round-trip that acquires and immediately releases, so this is cheap
/// — but it only runs in a non-owner tab, and stops for good the first time it succeeds.
/// </remarks>
public TimeSpan TakeoverPollInterval { get; set; } = TimeSpan.FromSeconds(2);

/// <summary>
/// Whether the owning tab asks the browser to exempt this origin's storage from eviction
/// (<c>navigator.storage.persist()</c>). Defaults to <see langword="true" />.
/// </summary>
/// <remarks>
/// <para>
/// Worth asking, because the snapshots this package writes live in IndexedDB, and IndexedDB is
/// evictable: under storage pressure a browser may discard them, and the database would come
/// back empty on the next load with nothing to indicate why. A refusal costs nothing — the app
/// works exactly as before — so the default is to ask.
/// </para>
/// <para>
/// Chromium decides from engagement heuristics without prompting. <b>Firefox shows a permission
/// prompt</b>, and this is asked during startup rather than from a click, so an app that would
/// rather choose its moment should set this to <see langword="false" /> and call
/// <c>IStorageEstimator.RequestPersistAsync()</c> from a user-gesture handler instead.
/// </para>
/// <para>Only the owning tab asks: the others persist nothing, so a prompt there would buy nothing.</para>
/// </remarks>
public bool RequestPersistentStorage { get; set; } = true;

/// <summary>
/// Where the database file lives, resolved from <see cref="Name" /> when the options are validated.
/// </summary>
Expand Down Expand Up @@ -55,5 +86,11 @@ internal void Validate()
{
throw new InvalidOperationException($"{nameof(Retain)} must be at least 1 (was {Retain}).");
}

if (TakeoverPollInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"{nameof(TakeoverPollInterval)} must be positive (was {TakeoverPollInterval}).");
}
}
}
Loading