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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
Name: A page no longer breaks because its code was read while still being saved
Category: Fix
Description: A freshly built page could be picked up a moment before it had finished saving, which made it look permanently broken even though nothing was wrong with it. Builds now become visible only once they are complete.
Icon: Bug
Order: -20260813
---

# A page no longer breaks because its code was read while still being saved

When you edit the code behind a page, the portal builds it and saves the result so every part
of the portal can use it. The saved file was being created first and filled in immediately
afterwards — a gap of milliseconds, but a real one. Anything that picked the build up inside
that gap got half of it.

Half a build is not a build, so the portal reported it the only way it could: as a failure to
compile. That was the damaging part. A page that had actually built perfectly well was marked
broken, and because a compile failure is treated as a settled answer, it was never retried —
the page stayed broken until someone rebuilt it by hand, and the error message pointed at the
page's code, where there was nothing to find.

It was easiest to hit exactly where it hurt most: on a busy portal, and on installations that
run several copies of the portal sharing one location for builds, where one copy would read
what another was still writing. A page marked broken also holds up the portal reporting itself
as ready, so a single mis-read could slow a restart for everyone.

A build is now written aside and moved into place in one step, so it becomes visible only once
it is whole. There is no longer a moment in which a page can be read half-built.
38 changes: 34 additions & 4 deletions src/MeshWeaver.Graph/Configuration/FileSystemAssemblyStore.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reactive.Linq;
using System.Security.Cryptography;
using MeshWeaver.Mesh.Persistence;
using MeshWeaver.Mesh.Services;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -140,11 +141,40 @@ public IObservable<AssemblyStoreLocation> PutWithLocation(string nodeTypePath, l
var pdbPath = Path.ChangeExtension(dllPath, ".pdb");
var relativeContentPath = Path.GetRelativePath(rootDirectory, dllPath).Replace('\\', '/');

File.WriteAllBytes(dllPath, assemblyBytes);
// 🚨 ATOMIC PUBLICATION — never File.WriteAllBytes on dllPath (MeshWeaver#1387).
// The DLL's NAME is its publication: both TryGetAssemblyPath above and the
// first-write-wins probe a few lines up discover it by globbing
// `v{version}-{tag}-*.dll`, and the winner's path goes straight to
// AssemblyLoadContext.LoadFromAssemblyPath. FileMode.Create (what WriteAllBytes uses)
// creates the target FIRST and streams the bytes afterwards, so a reader that globs
// inside that window loads a TRUNCATED PE image. The header is intact, so the load
// itself succeeds and the first Assembly.GetTypes() throws
// ReflectionTypeLoadException: Could not load type 'X' from assembly
// 'DynamicNode_…' because the format is invalid
// which CompileResultFromAssembly records as a compile failure — and that failure is
// TERMINAL: it writes CompilationStatus.Error and the first-build kickoff (gated on
// Status == null) never retries, so one transient torn read PARKS the NodeType until
// someone deletes the file. A parked NodeType refuses portal readiness.
//
// The reader is not even necessarily in this process: on AKS this directory is
// /data/assembly-cache, a ReadWriteMany Azure Files share, so every replica globs the
// bytes another replica is mid-write on.
//
// Publish the PDB before the DLL: the DLL is the discovery key, so anything visible to
// a reader is complete AND already has its symbols.
if (pdbBytes is { Length: > 0 })
File.WriteAllBytes(pdbPath, pdbBytes);
logger.LogInformation(
"Cached assembly at {DllPath} ({Bytes} bytes)", dllPath, assemblyBytes.Length);
AtomicFileWrite.PublishBytes(pdbPath, pdbBytes);
var published = AtomicFileWrite.PublishBytes(dllPath, assemblyBytes);
if (published)
logger.LogInformation(
"Cached assembly at {DllPath} ({Bytes} bytes)", dllPath, assemblyBytes.Length);
else
// Another writer (or another replica through the shared volume) published the same
// content-hashed name first. The bytes are identical by construction — the hash IS
// the name — so this is a no-op, not a conflict.
logger.LogDebug(
"Assembly already published at {DllPath} by a concurrent writer — kept theirs "
+ "(identical bytes: the content hash is the file name)", dllPath);
return Observable.Return(new AssemblyStoreLocation(dllPath, FileSystemCollectionName, relativeContentPath));
}

Expand Down
30 changes: 26 additions & 4 deletions src/MeshWeaver.Hosting.AzureBlob/BlobAssemblyStore.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Reactive.Linq;
using Azure;
using Azure.Storage.Blobs;
using MeshWeaver.Mesh.Persistence;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Mesh.Threading;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -84,9 +85,20 @@ public BlobAssemblyStore(
try
{
Directory.CreateDirectory(Path.GetDirectoryName(localPath)!);
await dllBlob.DownloadToAsync(localPath, ct).ConfigureAwait(false);
try { await pdbBlob.DownloadToAsync(Path.ChangeExtension(localPath, ".pdb"), ct).ConfigureAwait(false); }
// 🚨 Materialise through a temp file + rename (MeshWeaver#1387). localPath is the
// exact name the File.Exists fast path above discovers by, and the winner's path
// goes to AssemblyLoadContext.LoadFromAssemblyPath — so downloading straight into
// it publishes a growing, partially-written PE image under its final name. The
// pdb is published first so a discoverable DLL always has its symbols.
try
{
await AtomicFileWrite.PublishAsync(
Path.ChangeExtension(localPath, ".pdb"),
temp => pdbBlob.DownloadToAsync(temp, ct)).ConfigureAwait(false);
}
catch (RequestFailedException rfe) when (rfe.Status == 404) { /* pdb optional */ }
await AtomicFileWrite.PublishAsync(
localPath, temp => dllBlob.DownloadToAsync(temp, ct)).ConfigureAwait(false);
logger.LogInformation(
"Hydrated {NodeTypePath}@v{Version} from blob to {LocalPath}",
nodeTypePath, version, localPath);
Expand Down Expand Up @@ -115,10 +127,20 @@ public IObservable<AssemblyStoreLocation> PutWithLocation(string nodeTypePath, l

// Write to local cache first so the caller can load immediately without waiting
// on the upload round-trip.
//
// 🚨 Through a temp file + rename (MeshWeaver#1387): localPath is the exact name
// TryGetAssemblyPath's File.Exists fast path discovers by, and the discovered path is
// handed to AssemblyLoadContext.LoadFromAssemblyPath. File.WriteAllBytesAsync opens
// FileMode.Create — target first, bytes after — so writing it directly publishes a
// truncated PE image under its final name for the duration of the write. Replace
// semantics are correct here (and atomic): this cache is a process-local directory,
// and the key carries no framework tag, so a new image must be able to overwrite the
// previous image's bytes for the same version.
Directory.CreateDirectory(Path.GetDirectoryName(localPath)!);
await File.WriteAllBytesAsync(localPath, assemblyBytes, ct).ConfigureAwait(false);
if (pdbBytes is { Length: > 0 })
await File.WriteAllBytesAsync(Path.ChangeExtension(localPath, ".pdb"), pdbBytes, ct).ConfigureAwait(false);
await AtomicFileWrite.ReplaceAllBytesAsync(
Path.ChangeExtension(localPath, ".pdb"), pdbBytes, ct).ConfigureAwait(false);
await AtomicFileWrite.ReplaceAllBytesAsync(localPath, assemblyBytes, ct).ConfigureAwait(false);

// Then upload. Overwrite=true is safe because two replicas compiling the same
// version produce (near-)identical bytes.
Expand Down
55 changes: 0 additions & 55 deletions src/MeshWeaver.Hosting/Persistence/AtomicFileWrite.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using MeshWeaver.Hosting.Persistence.Parsers;
using MeshWeaver.Markdown;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Persistence;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Mesh.Threading;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Reactive.Linq;
using System.Text.Json;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Persistence;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Mesh.Threading;

Expand Down
Loading
Loading