Publishing A File On A Shared Volume

Every portal replica shares one /data, an Azure Files (SMB) ReadWriteMany volume, and several things on it are published by NAME: a module's activation records (modules/activation.d/<Name>/…), the module set index (modules/sets/…), a compiled NodeType assembly, a container blob, a cached package. For each of them the rule is the same:

A name a reader watches appears holding the WHOLE file, or it does not appear.

The staging-file-then-rename shape is how that is kept — AtomicFileWrite's type remarks carry the three torn reads that shape it. What this page is about is the step everyone believed was the rename.

File.Move(staged, target, overwrite: false) is not a rename

.NET's no-overwrite move on Unix is three steps, and only the first is atomic (FileSystem.Unix.csMoveFileLinkOrCopyFile):

  1. lstat both paths; if the target is absent, rename(2) — atomic, and the intended path;
  2. if that rename fails for any reason, link(2) — also atomic, where the volume has hard links;
  3. if the link fails with EXDEV/EACCES/EPERM/EOPNOTSUPP/EMLINK/ENOSYS, copy the staged file into the target, opening it FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None and filling it afterwards.

Azure Files over SMB has no hard links, so on the portals' volume step 2 always fails and every failed rename lands in step 3. For the whole copy the final name exists, holds incomplete bytes, and is held under FileShare.None — which .NET implements on Unix as an exclusive flock.

Measured

With the portal image's own runtime (3.0.0-ci.8323, .NET 10), one File.Move(staged, target, overwrite: false) of a 512 MiB file that could not be renamed, while a second thread polled the target with the same sharing mode the activation reader uses (FileShare.ReadWrite | FileShare.Delete):

Reader Opens that failed Opens that read it INCOMPLETE Opens that found it absent
Same kernel (lock visible) 21,573"The process cannot access the file … because it is being used by another process" 0 0
Lock not visible (DOTNET_SYSTEM_IO_DISABLEFILELOCKING=1) 0 52,619 0

The second row is not a curiosity: the fleet's azurefile-memex StorageClass mounts with nobrl, so a flock is local to one node's kernel. A reader on another node sees no lock at all — it reads the half-copied file.

absent = 0 in both rows is the point. The name is never missing during the copy; it is present and wrong.

What that cost

memex, 2026-09-16 13:19:55Z, pod memex-portal-deployment-7cb6684584-jdw7v:

fail: MeshWeaver.PluginCatalog.ModuleLandingService[0]
      Module record '/data/modules/activation.d/MeshWeaver.AI.Anthropic/…verdict' could not be read
      (IOException: The process cannot access the file … because it is being used by another process.)
fail: MeshWeaver.PluginCatalog.ModuleLandingService[0]
      Module 'MeshWeaver.AI.Anthropic' is NOT loaded from this read: 1 of its records could not be read.

The second line is the designed consequence, not a second defect: a module whose records cannot be read WHOLE is dropped from the read (Module Activation Head OwnershipFail closed), because deriving from a partial log would load an uninstalled module or promote an older generation. At BOOT that answer is the process's for its lifetime — the pod runs without the module until it is restarted, and reports it as pending. The same window makes ProposeModuleSet refuse (a set must never be proposed from a partial read), and makes a modules-GC pass skip every generation delete.

So the reader cannot be made to cope: the writer must never create a final name any way but by renaming a complete file.

The primitive: NoReplaceMove

MeshWeaver.Utils.NoReplaceMove.TryMove(staged, target) publishes a staged file under a name that does not exist yet, and has no copy in it at all.

Platform Step Outcome
Linux renameat2(…, RENAME_NOREPLACE) published · EEXISTfalse · unsupported (EINVAL/ENOSYS/EOPNOTSUPP, or a C library without the call) → link
macOS renamex_np(…, RENAME_EXCL) as above (ENOTSUP → link)
Windows MoveFileEx without replace published · exists → false · a staged file on another volume → refused, never copied
any link(2) + unlink(2) of the staged name the same atomic no-replace create where the volume has hard links
any neither available refusedIOException, nothing written under the final name

RENAME_NOREPLACE is the native CIFS behaviour (fs/smb/client/inode.c: "No-replace is the natural behavior for CIFS, so skip unlink hacks"), so on Azure Files it is one server operation: the name appears complete, or the call reports EEXIST having touched nothing.

A refusal is the right answer, and it is loud. Where the rename cannot be made, the landing fails with the platform error in the message and HResult; its bytes and its previous records are untouched, and the next landing or the next boot records the event again. The alternative — the old behaviour — was to publish something no reader may see.

false is not a failure. Records, sets, blobs and cached packages are content-addressed, so a name already taken already holds these bytes: the loser of the race deletes its staging file and carries on. Nothing is ever replaced, so a reader holding the winner's file never sees the name vanish or change under it.

Where it is used, and what the guard keeps

Every no-replace publication in the tree goes through it: ModuleActivationSidecar.WriteOnce (landing records, uninstall tombstones, platform verdicts, older-image snapshots), ModuleSetStore.WriteOnce, AtomicFileWrite.PublishBytes / PublishAsync (the assembly cache and everything else that publishes bytes), FileSystemNuGetPackageCache.SaveAsync, ModuleLandingService.RestoreMissingFiles and EmitReferenceCapture.

NoReplaceMoveRatchetGuard (in test/MeshWeaver.Documentation.Test) scans src, tools, samples and memex for File.Move( without overwrite: true and fails on any site but the primitive's own. There is no allow file.

File.Move(…, overwrite: true) stays allowed and is a different operation: a deliberate REPLACE, implemented as a plain rename(2) whose only fallback is across devices — which a sibling staging file cannot reach.

What is still not atomic

See also

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.