Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s

This commit is contained in:
2026-08-04 16:38:42 +02:00
31 changed files with 3285 additions and 13 deletions
@@ -523,6 +523,90 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("bad day");
}
/// <summary>
/// The whole point of the push channel: a pass that did not wait for the minute.
/// </summary>
/// <remarks>
/// <para>
/// The timing is what makes this an assertion rather than a hope. The background timer is a full
/// minute and the wait below gives up in ten seconds, so a pull that arrives can only have been
/// caused by the notice — there is no interval at which the timer could have produced it.
/// </para>
/// <para>
/// The vault id in the notice is arbitrary, and deliberately so: a pass synchronises every vault
/// this session can reach, so the loop reads the notice as "there is something to fetch" and never
/// as "fetch this one". A test that seeded a real id would imply a targeting this does not do.
/// </para>
/// </remarks>
[Fact]
public async Task APushedNotice_SynchronisesWithoutWaitingForTheTimer()
{
await UnlockedAsync();
// The unlock starts the loop, whose first act is a pass; waited out so the count below is a
// baseline rather than a race with it.
await EventuallyAsync(
() => server.PullCount > 0,
"the pass on open should have run");
var before = server.PullCount;
server.Notices.Push(Guid.CreateVersion7());
await EventuallyAsync(
() => server.PullCount > before,
"a notice should have woken the loop long before the one-minute timer");
}
/// <remarks>
/// The half that is easy to get wrong. The loop selects between two waits, and both have to survive
/// losing: <c>PeriodicTimer</c> throws if a second wait is started while one is outstanding, and an
/// abandoned channel read stays registered and swallows the next notice written. Either defect
/// leaves the first notice working and every one after it silently lost, which is why one notice is
/// not enough to prove this.
/// </remarks>
[Fact]
public async Task NoticesKeepWakingTheLoop_NotJustTheFirst()
{
await UnlockedAsync();
await EventuallyAsync(() => server.PullCount > 0, "the pass on open should have run");
for (var round = 1; round <= 3; round++)
{
var before = server.PullCount;
server.Notices.Push(Guid.CreateVersion7());
await EventuallyAsync(
() => server.PullCount > before,
$"notice {round} should have woken the loop as the first one did");
}
}
/// <summary>Waits for something a background loop is expected to do, or fails saying what.</summary>
/// <remarks>
/// Polled rather than signalled because the thing under test is a loop nobody hands a completion
/// source to. The bound is generous — this is not measuring latency, only proving that the timer
/// cannot be what caused the result.
/// </remarks>
private static async Task EventuallyAsync(Func<bool> condition, string because)
{
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
while (TimeProvider.System.GetUtcNow() < deadline)
{
if (condition())
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
}
throw new ShouldAssertException(because);
}
/// <remarks>
/// <para>
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing