Skip to content
Closed
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
32 changes: 17 additions & 15 deletions src/MeshWeaver.ContentCollections/ContentCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,20 +141,17 @@ public async Task SaveFileAsync(string path, string fileName, Stream openReadStr
{
await provider.SaveFileAsync(path, fileName, openReadStream);

// 🚨 Read-after-write for the collection's OWN writes must NOT depend on the file-system
// watcher. The watcher (AttachMonitor) is the only thing that feeds a post-init write into
// markdownStream — but on Linux inotify DROPS the event for a file written into a
// just-created subdirectory (the recursive watch on the new dir isn't registered before the
// write), so the article never lands and a content render stays "not found" until the
// collection re-initializes. That is the CI-only CollectionNamedArea flake AND the prod
// "upload a file then open it → shows nothing" bug this test class was written for. macOS
// FSEvents watches the whole tree so it never misses — which is why it only ever flaked on
// the Linux runner. Ingest our own write directly; the watcher remains for EXTERNAL changes.
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
var folder = path.Trim('/');
UpdateArticle(folder.Length == 0 ? fileName : $"{folder}/{fileName}");
}
// 🚨 Read-your-writes: merge a just-written markdown file into the stream RIGHT NOW instead
// of waiting for the FileSystemWatcher. The watcher is asynchronous and lossy — its events
// are coalesced and its internal buffer overflows under load, so a "save then view" (an
// upload, or a save→render) could hang until a watcher event that may arrive late or NEVER
// (the CI flake: the file-content render reduces `markdownStream`, which only the watcher
// populated → the article never lands → the render emits null → a 30s render timeout). The
// watcher stays the path for OUT-OF-BAND (external) changes; a save we performed is
// reflected deterministically here. Same merge path the watcher uses (idempotent if both fire).
var relativePath = $"{path.Trim('/')}/{fileName}".TrimStart('/');
if (MarkdownFilter(relativePath))
UpdateArticle(relativePath);
}

/// <summary>
Expand Down Expand Up @@ -263,9 +260,14 @@ protected void UpdateArticle(string path)
);
}

/// <summary>Subscribes the collection to backing-store change notifications, routing each to <see cref="UpdateArticle"/>.</summary>
/// <summary>Subscribes the collection to backing-store change notifications, routing each to
/// <see cref="UpdateArticle"/>. No-op when <see cref="ContentCollectionConfig.DisableFileWatcher"/>
/// is set — read-your-writes (<see cref="SaveFileAsync"/>'s own merge) keeps in-process saves live
/// without it; only OUT-OF-BAND edits go unseen.</summary>
protected void AttachMonitor()
{
if (Config.DisableFileWatcher)
return;
monitorDisposable = provider.AttachMonitor(UpdateArticle);
}

Expand Down
10 changes: 10 additions & 0 deletions src/MeshWeaver.ContentCollections/ContentCollectionConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ public record ContentCollectionConfig
/// </summary>
public bool IsStatic { get; set; }

/// <summary>
/// Disables the backing-store change monitor (the <c>FileSystemWatcher</c>). Off by default —
/// external / out-of-band edits are picked up live. Set <c>true</c> where the watcher is
/// unreliable (network mounts, high load — its OS event buffer coalesces and overflows), trading
/// live external-change detection for determinism: a save the process performed is still reflected
/// immediately (<see cref="ContentCollection.SaveFileAsync"/> merges its own write — read-your-writes),
/// so uploads and save→render never depend on a watcher event that may arrive late or never.
/// </summary>
public bool DisableFileWatcher { get; set; }

/// <summary>
/// Whether this collection should be visible to child nodes in the hierarchy.
/// When false, only the node that owns the collection can see it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using MeshWeaver.Data;
Comment on lines +1 to +5
using MeshWeaver.Fixture;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Client;
using MeshWeaver.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace MeshWeaver.ContentCollections.Test;

/// <summary>
/// Regression guard for the CI flake in
/// <c>NodeHubContentCollectionTest.CollectionNamedArea_RendersBrowserForFolder_AndContentForFile</c>:
/// the file-content render reduces the collection's markdown stream, which used to be populated ONLY
/// by the <c>FileSystemWatcher</c>. The watcher's events are asynchronous and lossy (coalesced, its
/// OS buffer overflows under load), so a save→render could hang until an event that arrived late — or
/// never — → a 30s render timeout (<c>ObservableAssertionException : did not emit within 30s</c>).
/// <para>
/// This runs the SAME save→render, but with the watcher turned OFF (<c>DisableFileWatcher</c>), so the
/// file can reach the stream ONLY through <see cref="ContentCollection.SaveFileAsync"/>'s own merge
/// (read-your-writes). With the fix it renders deterministically; if the merge regresses, this test
/// deterministically times out instead of silently re-introducing a load-dependent flake elsewhere.
/// </para>
/// </summary>
public class SaveFileReadYourWritesTest(ITestOutputHelper output) : HubTestBase(output)
{
private readonly string _contentPath = Path.Combine(
AppContext.BaseDirectory, "Files", "NoWatchContent", Guid.NewGuid().ToString("N"));

protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration configuration)
=> base.ConfigureHost(configuration).AddContentCollections();

protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration)
{
Directory.CreateDirectory(_contentPath);
return base.ConfigureClient(configuration)
.AddContentCollections()
.AddContentCollection(_ => new ContentCollectionConfig
{
Name = "content",
SourceType = "FileSystem",
IsEditable = true,
ExposeInChildren = true,
DisableFileWatcher = true, // ← the point: no watcher; only the save-time merge can deliver
BasePath = _contentPath,
Settings = new Dictionary<string, string> { ["BasePath"] = _contentPath }
});
}

[HubFact]
public async Task File_Rendered_Right_After_Save_Without_Watcher()
{
var client = GetClient();
var contentService = client.ServiceProvider.GetRequiredService<IContentService>();
var ct = TestContext.Current.CancellationToken;
var collection = await contentService.GetCollectionAsync("content", ct);
collection.Should().NotBeNull();

using (var stream = new MemoryStream("# Read your writes — no watcher"u8.ToArray()))
await collection!.SaveFileAsync("/notes", "hello.md", stream);

// Render the just-saved file. With the watcher off, the ONLY way the content reaches the
// markdown stream (and thus this render) is SaveFileAsync's own merge.
var fileControl = await GetHost().GetWorkspace()
.GetRemoteStream<System.Text.Json.JsonElement, LayoutAreaReference>(
client.Address, new LayoutAreaReference("content") { Id = "notes/hello.md" })
.GetControlStream("content")
.Should().Within(30.Seconds())
.Match(c => c != null && c.ToString()!.Contains("Read your writes"));

fileControl!.ToString().Should().Contain("Read your writes");
}
}
Loading