Files
UA-.NETStandard/src/Opc.Ua.Server/FileSystem/FileDirectoryBinder.cs
T
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# Description

_Describe the changes here to communicate to the maintainers why they
should accept this pull request. By default - this will become the
Commit message after merging and thus define history._

Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists
companions using the existing Machinery/MachineVision fluent, DI and
typed-client approach. **The PR is open/non-draft by user choice.
Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and
has 108 successful checks, six intentionally skipped checks, zero
failures and zero pending checks. All five primary workflows completed
successfully at that exact head.**

| Family | Released specification/model | Packaging |
| --- | --- | --- |
| MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP;
normal root versions |
| WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire
namespace and independent WMTP-only hosting |
| GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model,
Server, Client and MCP; normal root versions |
| CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all
four plus Debug variants forced preview |

There are **12 new packable projects**. No separate WMTP
package/profile/guide, root version bump, tag, release or package
publication is included. Mandatory instance structure is the default;
selected optional features require explicit providers. The
implementation reuses Machinery results/jobs/files/ProcessValues and
Robotics topology, including retained MachineTool Production, defined
GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and
isolated instances alongside MachineVision.

Runtime and integration fixes preserve raw quality/timestamps, coherent
acquisition, namespace ownership, accepted-configuration state,
borrowed-resource lifetime, session/node/file cleanup, terminal-value
publication, event propagation and endpoint-aware capability
publication. Firmware/licence transfer means provider acceptance, not
installation/activation. Cancellation does not silently abort another
caller's operation. The later upstream MCP expansion is integrated
without losing either family set or its default-off privileged/live
execution policy.

### Conformance and scope

Canonical model inputs are pinned to UA-Nodeset revision
`96a08945425d0295edb3607d2a38f6841ef44046`.

**GMS upstream exception:** [Annex
A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes
the released NodeSet normative, but §12.3.2.7 requires undefined
`GMS_ProductionStatisticsType`. Inherited statistics and defined
jobs/parts are implemented without inventing a standard type/NodeId; the
complete GMS Production facet is withheld.

The requirement ledger records **102 positively evidenced CU rows, 96
directly negative-tested rows and six explicit negative-evidence
limitations**. All 648 test-class/name/TRX links were independently
resolved to passing cases. The six limitations concern Stacklight,
retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit,
Obligation and MaintenanceMode. They are not claims that every
invalid-input or hardware-control behavior was tested. The undefined GMS
requirement is outside the 102 defined CUs. No external certification
claim is made.

Retained baseline complete-assembly line coverage, with generated code
retained and no source exclusions:

| Family | Model | Server | Client |
| --- | ---: | ---: | ---: |
| MachineTool/WMTP | 83.80% | 91.98% | 84.74% |
| GMS | 95.51% | 90.63% | 88.38% |
| CranesHoists | 92.76% | 94.45% | 92.24% |

These nine measurements predate the later focused review fixes and
merges; they are not relabelled as new `ecc4a7d6c` percentages. The
current head's separate CI code-coverage check succeeded. No new
exclusions or threshold relaxations were introduced.

## Related Issues

- Fixes
[#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764),
the implementation/design and acceptance tracker.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-15 15:15:46 +02:00

1076 lines
44 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Opc.Ua.Server.FileSystem
{
/// <summary>
/// Options that control how a bound FileDirectoryType materialises provider entries.
/// </summary>
public sealed class FileDirectoryBindingOptions
{
/// <summary>
/// Gets or sets a value indicating whether CreateFile and CreateDirectory are exposed as writable operations.
/// </summary>
public bool AllowCreate { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether DeleteFileSystemObject is exposed as a writable operation.
/// </summary>
public bool AllowDelete { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether MoveOrCopy is exposed as a writable operation.
/// </summary>
public bool AllowMoveOrCopy { get; set; } = true;
/// <summary>
/// Gets or sets the maximum number of entries materialised per directory.
/// </summary>
public int MaxEntries { get; set; } = DefaultMaxEntries;
/// <summary>
/// Gets or sets the maximum directory nesting materialised below the bound root.
/// </summary>
public int MaxDepth { get; set; } = 8;
internal const int DefaultMaxEntries = 1024;
}
/// <summary>
/// Represents an active binding between a FileDirectoryType node and an IFileSystemProvider.
/// </summary>
public interface IFileDirectoryBinding : IAsyncDisposable
{
/// <summary>
/// Gets the bound directory node.
/// </summary>
FileDirectoryState Directory { get; }
/// <summary>
/// Gets the provider that backs the directory node.
/// </summary>
IFileSystemProvider Provider { get; }
/// <summary>
/// Re-reads the provider and reconciles materialised child nodes.
/// </summary>
/// <param name="cancellationToken">The token used to cancel reconciliation.</param>
/// <returns>A task that completes when reconciliation finishes, or reports the refresh failure.</returns>
ValueTask RefreshAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Creates bindings that back existing FileDirectoryType nodes with file-system providers.
/// </summary>
/// <remarks>
/// With a ServerSystemContext, bindings observe the server session lifecycle and release the closing
/// session's file handles, including pending opens. The application owns the binding's lifetime and
/// must dispose it when its directory is removed or the node manager shuts down.
/// </remarks>
public interface IFileDirectoryBinder
{
/// <summary>
/// Binds a FileDirectoryType node to a provider and materialises its current contents.
/// </summary>
/// <param name="directory">The existing directory node to bind.</param>
/// <param name="provider">The file-system provider that backs the directory.</param>
/// <param name="context">The system context used to create and register child nodes.</param>
/// <param name="options">The binding options, or null to use the defaults.</param>
/// <param name="registerNode">The callback that registers a materialised node with its node manager.</param>
/// <param name="deregisterNode">The callback that removes a materialised node from its node manager.</param>
/// <param name="cancellationToken">The token used to cancel initial materialisation.</param>
/// <returns>The initialized binding, which the caller must dispose asynchronously.</returns>
ValueTask<IFileDirectoryBinding> BindAsync(
FileDirectoryState directory,
IFileSystemProvider provider,
ISystemContext context,
FileDirectoryBindingOptions? options = null,
Func<NodeState, CancellationToken, ValueTask>? registerNode = null,
Func<NodeState, CancellationToken, ValueTask>? deregisterNode = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default FileDirectoryType binder implementation.
/// </summary>
public sealed class FileDirectoryBinder : IFileDirectoryBinder
{
/// <inheritdoc/>
public async ValueTask<IFileDirectoryBinding> BindAsync(
FileDirectoryState directory,
IFileSystemProvider provider,
ISystemContext context,
FileDirectoryBindingOptions? options = null,
Func<NodeState, CancellationToken, ValueTask>? registerNode = null,
Func<NodeState, CancellationToken, ValueTask>? deregisterNode = null,
CancellationToken cancellationToken = default)
{
if (directory == null)
{
throw new ArgumentNullException(nameof(directory));
}
if (provider == null)
{
throw new ArgumentNullException(nameof(provider));
}
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var binding = new FileDirectoryBinding(
directory,
provider,
context,
options ?? new FileDirectoryBindingOptions(),
registerNode,
deregisterNode);
bool initialized = false;
try
{
await binding.InitializeAsync(cancellationToken).ConfigureAwait(false);
initialized = true;
return binding;
}
finally
{
if (!initialized)
{
await binding.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Owns materialized directory nodes, open handles, and serialized provider reconciliation.
/// </summary>
private sealed class FileDirectoryBinding : IFileDirectoryBinding, IFileSystemHost
{
/// <summary>
/// Creates a directory binding with validated materialization limits and an optional node registrar.
/// </summary>
public FileDirectoryBinding(
FileDirectoryState directory,
IFileSystemProvider provider,
ISystemContext context,
FileDirectoryBindingOptions options,
Func<NodeState, CancellationToken, ValueTask>? registerNode,
Func<NodeState, CancellationToken, ValueTask>? deregisterNode)
{
Directory = directory;
Provider = provider;
m_context = context;
m_logger = context.Telemetry.CreateLogger<FileDirectoryBinder>();
m_options = ValidateOptions(options);
m_registerNode = registerNode;
m_deregisterNode = deregisterNode;
m_nodeIdPrefix = "FileDirectoryBinding:" + directory.NodeId;
}
/// <inheritdoc/>
public FileDirectoryState Directory { get; }
/// <inheritdoc/>
public IFileSystemProvider Provider { get; }
/// <inheritdoc/>
public bool AllowCreate => m_options.AllowCreate;
/// <inheritdoc/>
public bool AllowDelete => m_options.AllowDelete;
/// <inheritdoc/>
public bool AllowMoveOrCopy => m_options.AllowMoveOrCopy;
/// <inheritdoc/>
public bool UsesVirtualDirectoryBrowsing => false;
/// <inheritdoc/>
public async ValueTask RefreshAsync(CancellationToken cancellationToken = default)
{
if (!TryTrackOperation())
{
return;
}
bool entered = false;
try
{
await m_gate.WaitAsync(cancellationToken).ConfigureAwait(false);
entered = true;
// The binding may have been disposed while this refresh was
// queued behind another one.
if (m_disposed)
{
return;
}
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
CompleteOperation(entered);
}
}
/// <summary>
/// Tears the binding down under the same gate a refresh takes, so a
/// refresh that is in flight or queued completes first. Disposing
/// the gate underneath a refresh would fault its release and let
/// the teardown race the node materialisation.
/// </summary>
public async ValueTask DisposeAsync()
{
if (!TryTrackOperation())
{
return;
}
bool entered = false;
try
{
await m_gate.WaitAsync().ConfigureAwait(false);
entered = true;
if (m_disposed)
{
return;
}
FileHandle[] handles;
lock (m_lock)
{
m_disposed = true;
m_lookupById = [];
handles = new FileHandle[m_handles.Count];
m_handles.Values.CopyTo(handles, 0);
m_handles.Clear();
}
DetachSessionLifecycle();
DetachDirectoryCallbacks(Directory);
foreach (MaterializedNode entry in m_nodesByPath.Values)
{
DetachCallbacks(entry.Node);
}
foreach (FileHandle handle in handles)
{
handle.Dispose();
}
await RemoveStaleNodesAsync([], CancellationToken.None).ConfigureAwait(false);
}
finally
{
CompleteOperation(entered);
}
}
/// <inheritdoc/>
public NodeId BuildDirectoryNodeId(string providerPath)
{
return string.IsNullOrEmpty(providerPath)
? Directory.NodeId
: CreateMaterializedNodeId("dir", providerPath);
}
/// <inheritdoc/>
public NodeId BuildFileNodeId(string providerPath)
{
return CreateMaterializedNodeId("file", providerPath);
}
/// <inheritdoc/>
public string CombineProviderPath(string parent, string name)
{
if (string.IsNullOrEmpty(parent))
{
return name;
}
return parent.TrimEnd('/') + "/" + name;
}
/// <inheritdoc/>
public NodeId GetParentNodeId(string providerPath)
{
if (string.IsNullOrEmpty(providerPath))
{
return NodeId.Null;
}
int slash = providerPath.LastIndexOf('/');
string parent = slash < 0 ? string.Empty : providerPath[..slash];
return string.IsNullOrEmpty(parent) ? Directory.NodeId : BuildDirectoryNodeId(parent);
}
/// <inheritdoc/>
public FileHandle? GetOrCreateHandle(NodeId nodeId, string providerPath)
{
if (nodeId.NamespaceIndex != Directory.NodeId.NamespaceIndex)
{
return null;
}
EnsureSessionLifecycle();
string identity = FileSystemDirectoryOperations.GetPathIdentity(Provider, providerPath);
lock (m_lock)
{
if (m_disposed)
{
return null;
}
if (m_handles.TryGetValue(identity, out FileHandle? handle))
{
return handle;
}
handle = new FileHandle(Provider, providerPath, IsSessionTerminated);
FileSystemDirectoryOperations.BlockForRunningMutations(Provider, m_mutations, handle);
m_handles.Add(identity, handle);
return handle;
}
}
/// <inheritdoc/>
public FileHandle? FindHandle(string providerPath)
{
string identity = FileSystemDirectoryOperations.GetPathIdentity(Provider, providerPath);
lock (m_lock)
{
return !m_disposed && m_handles.TryGetValue(identity, out FileHandle? handle) ? handle : null;
}
}
/// <inheritdoc/>
public void ReleaseHandle(FileHandle handle)
{
string identity = FileSystemDirectoryOperations.GetPathIdentity(Provider, handle.ProviderPath);
lock (m_lock)
{
if (m_handles.TryGetValue(identity, out FileHandle? current) &&
ReferenceEquals(current, handle) &&
handle.TryRetire())
{
m_handles.Remove(identity);
}
}
}
/// <inheritdoc/>
public void ForgetHandle(NodeId nodeId)
{
FileHandle? retired = null;
lock (m_lock)
{
if (m_lookupById.TryGetValue(nodeId, out MaterializedNode? entry))
{
string identity = FileSystemDirectoryOperations.GetPathIdentity(Provider, entry.ProviderPath);
if (m_handles.TryGetValue(identity, out retired))
{
m_handles.Remove(identity);
}
}
}
retired?.Dispose();
}
/// <inheritdoc/>
public bool TryBeginMutation(string providerPath)
{
lock (m_lock)
{
return FileSystemDirectoryOperations.TryBeginMutation(
Provider, m_handles.Values, m_mutations, providerPath);
}
}
/// <inheritdoc/>
public void EndMutation(string providerPath)
{
lock (m_lock)
{
FileSystemDirectoryOperations.EndMutation(
Provider, m_handles.Values, m_mutations, providerPath);
}
}
/// <inheritdoc/>
/// <remarks>
/// The bound directory lives in the integrator's address space, which
/// controls user access through its RolePermissions and the binding options.
/// </remarks>
public bool CanUserWrite(ISystemContext context)
{
return true;
}
/// <inheritdoc/>
public async ValueTask ApplyMutationAsync(
FileSystemMutationKind kind,
string path,
string targetPath,
NodeId sourceNodeId,
CancellationToken cancellationToken)
{
if (!TryTrackOperation())
{
throw new ServiceResultException(StatusCodes.BadShutdown);
}
bool entered = false;
try
{
await m_gate.WaitAsync(cancellationToken).ConfigureAwait(false);
entered = true;
if (m_disposed)
{
throw new ServiceResultException(StatusCodes.BadShutdown);
}
if (m_refreshRequired)
{
await RefreshForMutationAsync(mutationCommitted: false, cancellationToken)
.ConfigureAwait(false);
}
await CheckCapacityAsync(kind, path, targetPath, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
await FileSystemDirectoryOperations.ApplyProviderMutationAsync(
this, kind, path, targetPath, sourceNodeId, cancellationToken).ConfigureAwait(false);
await RefreshForMutationAsync(mutationCommitted: true, cancellationToken).ConfigureAwait(false);
}
finally
{
CompleteOperation(entered);
}
}
/// <inheritdoc/>
public bool TryGetProviderPath(
NodeId nodeId,
out string providerPath,
out bool isDirectory,
out bool isRoot)
{
lock (m_lock)
{
if (!m_disposed)
{
if (nodeId == Directory.NodeId)
{
providerPath = string.Empty;
isDirectory = true;
isRoot = true;
return true;
}
if (m_lookupById.TryGetValue(nodeId, out MaterializedNode? entry))
{
providerPath = entry.ProviderPath;
isDirectory = entry.IsDirectory;
isRoot = false;
return true;
}
}
}
providerPath = string.Empty;
isDirectory = false;
isRoot = false;
return false;
}
/// <summary>
/// Wires directory methods and completes the initial provider reconciliation before publishing the binding.
/// </summary>
public async ValueTask InitializeAsync(CancellationToken cancellationToken)
{
WireDirectoryCallbacks(Directory, providerPath: string.Empty);
m_initializing = true;
try
{
await RefreshAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
m_initializing = false;
}
}
/// <summary>
/// Attaches lazily because address-space setup can precede creation of the server session manager.
/// </summary>
private void EnsureSessionLifecycle()
{
// SessionManager raises callbacks under its own event gate. Never hold m_lock while
// subscribing or unsubscribing: the callback itself needs m_lock to snapshot the handles.
lock (m_sessionLifecycleLock)
{
if (!m_disposed && m_sessions == null &&
m_context is ServerSystemContext { Server.SessionManager: { } sessions })
{
m_sessions = sessions;
sessions.SessionClosing += OnSessionClosing;
}
}
}
/// <summary>
/// Removes lifecycle callbacks after admission has been stopped by binding disposal.
/// </summary>
private void DetachSessionLifecycle()
{
lock (m_sessionLifecycleLock)
{
if (m_sessions != null)
{
m_sessions.SessionClosing -= OnSessionClosing;
m_sessions = null;
}
}
}
/// <summary>
/// Checks the live session registry instead of retaining an unbounded set of terminated identifiers.
/// </summary>
private bool IsSessionTerminated(NodeId sessionId)
{
ISessionManager? sessions = Volatile.Read(ref m_sessions);
if (sessions == null)
{
return false;
}
foreach (ISession session in sessions.GetSessions())
{
if (session.Id == sessionId)
{
return SessionTermination.IsClosingOrClaimed(session) || session.HasExpired;
}
}
return true;
}
/// <summary>
/// Releases only the closing session, without waiting for a provider open or the reconciliation gate.
/// </summary>
private void OnSessionClosing(ISession session, SessionEventReason reason)
{
FileHandle[] handles;
lock (m_lock)
{
handles = [.. m_handles.Values];
}
foreach (FileHandle handle in handles)
{
try
{
handle.CloseSession(session.Id);
}
finally
{
ReleaseHandle(handle);
}
}
}
private bool TryTrackOperation()
{
lock (m_lock)
{
if (m_disposed)
{
return false;
}
m_activeOperations++;
return true;
}
}
private void CompleteOperation(bool entered)
{
if (entered)
{
m_gate.Release();
}
lock (m_lock)
{
if (--m_activeOperations == 0 && m_disposed)
{
m_gate.Dispose();
}
}
}
private async ValueTask CheckCapacityAsync(
FileSystemMutationKind kind,
string path,
string targetPath,
CancellationToken cancellationToken)
{
if (kind == FileSystemMutationKind.Delete)
{
return;
}
string destination = kind is FileSystemMutationKind.Move or FileSystemMutationKind.Copy
? targetPath
: path;
string parent = GetParentPath(destination);
if (kind == FileSystemMutationKind.Move && parent == GetParentPath(path))
{
return;
}
if (await Provider.GetEntryAsync(destination, cancellationToken).ConfigureAwait(false) != null)
{
return;
}
int count = 0;
await foreach (FileSystemEntry _ in Provider.EnumerateAsync(parent, cancellationToken)
.ConfigureAwait(false))
{
if (++count >= m_options.MaxEntries)
{
throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded,
"The directory cannot admit another entry within the configured binding limit.");
}
}
if (m_options.MaxEntries == 0)
{
throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded,
"The directory binding does not admit entries.");
}
}
private async ValueTask RefreshForMutationAsync(bool mutationCommitted, CancellationToken cancellationToken)
{
try
{
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or
NotSupportedException or ServiceResultException or InvalidOperationException ||
(mutationCommitted && ex is OperationCanceledException))
{
m_refreshRequired = true;
m_logger.FileDirectoryRefreshFailed(ex, Directory.NodeId, mutationCommitted);
}
}
private async ValueTask RefreshCoreAsync(CancellationToken cancellationToken)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
await ReconcileDirectoryAsync(Directory, string.Empty, 0, seen, cancellationToken)
.ConfigureAwait(false);
await RemoveStaleNodesAsync(seen, cancellationToken).ConfigureAwait(false);
if (m_initializing)
{
m_initializing = false;
foreach (MaterializedNode entry in m_nodesByPath.Values)
{
await RegisterNodeAsync(entry, cancellationToken).ConfigureAwait(false);
}
}
lock (m_lock)
{
m_lookupById = new Dictionary<NodeId, MaterializedNode>(m_nodesById);
}
m_refreshRequired = false;
}
private static string GetParentPath(string path)
{
int slash = path.LastIndexOf('/');
return slash < 0 ? string.Empty : path[..slash];
}
private async ValueTask ReconcileDirectoryAsync(
FileDirectoryState parent,
string providerPath,
int depth,
HashSet<string> seen,
CancellationToken cancellationToken)
{
List<FileSystemEntry> entries = [];
await foreach (FileSystemEntry entry in Provider.EnumerateAsync(providerPath, cancellationToken)
.ConfigureAwait(false))
{
if (entries.Count >= m_options.MaxEntries)
{
throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded,
"The directory contains more entries than the configured binding limit.");
}
entries.Add(entry);
}
foreach (FileSystemEntry entry in entries)
{
if (entry.IsDirectory)
{
if (depth >= m_options.MaxDepth)
{
continue;
}
FileDirectoryState child = await GetOrCreateDirectoryAsync(parent, entry, cancellationToken)
.ConfigureAwait(false);
seen.Add(entry.Path);
if (depth + 1 < m_options.MaxDepth)
{
await ReconcileDirectoryAsync(child, entry.Path, depth + 1, seen, cancellationToken)
.ConfigureAwait(false);
}
}
else
{
await GetOrCreateFileAsync(parent, entry, cancellationToken).ConfigureAwait(false);
seen.Add(entry.Path);
}
}
}
private async ValueTask<FileDirectoryState> GetOrCreateDirectoryAsync(
FileDirectoryState parent,
FileSystemEntry entry,
CancellationToken cancellationToken)
{
if (m_nodesByPath.TryGetValue(entry.Path, out MaterializedNode? existing))
{
if (existing.Node is FileDirectoryState directory)
{
await RegisterNodeAsync(existing, cancellationToken).ConfigureAwait(false);
return directory;
}
await RemoveNodeAsync(existing, cancellationToken).ConfigureAwait(false);
}
var node = new DirectoryObjectState(
m_context,
BuildDirectoryNodeId(entry.Path),
entry.Path,
entry.Name,
isRoot: false,
this);
parent.AddChild(node);
MaterializedNode materialized = AddMaterializedNode(entry.Path, node, isDirectory: true);
await RegisterNodeAsync(materialized, cancellationToken).ConfigureAwait(false);
return node;
}
private async ValueTask<FileState> GetOrCreateFileAsync(
FileDirectoryState parent,
FileSystemEntry entry,
CancellationToken cancellationToken)
{
if (m_nodesByPath.TryGetValue(entry.Path, out MaterializedNode? existing))
{
if (existing.Node is FileState file)
{
await RegisterNodeAsync(existing, cancellationToken).ConfigureAwait(false);
return file;
}
await RemoveNodeAsync(existing, cancellationToken).ConfigureAwait(false);
}
var node = new FileObjectState(m_context, BuildFileNodeId(entry.Path), entry.Path, entry.Name, this);
parent.AddChild(node);
MaterializedNode materialized = AddMaterializedNode(entry.Path, node, isDirectory: false);
await RegisterNodeAsync(materialized, cancellationToken).ConfigureAwait(false);
return node;
}
private async ValueTask RegisterNodeAsync(MaterializedNode entry, CancellationToken cancellationToken)
{
if (entry.Registered)
{
return;
}
if (m_registerNode == null)
{
entry.Registered = true;
return;
}
if (m_initializing)
{
return;
}
await m_registerNode(entry.Node, cancellationToken).ConfigureAwait(false);
entry.Registered = true;
}
private async ValueTask RemoveStaleNodesAsync(
HashSet<string> seen,
CancellationToken cancellationToken)
{
List<MaterializedNode> stale = [];
foreach (MaterializedNode entry in m_nodesByPath.Values)
{
if (!seen.Contains(entry.ProviderPath))
{
stale.Add(entry);
}
}
stale.Sort(static (left, right) => right.ProviderPath.Length.CompareTo(left.ProviderPath.Length));
foreach (MaterializedNode entry in stale)
{
await RemoveNodeAsync(entry, cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask RemoveNodeAsync(
MaterializedNode entry,
CancellationToken cancellationToken)
{
List<MaterializedNode> descendants = [];
string prefix = entry.ProviderPath + "/";
foreach (MaterializedNode candidate in m_nodesByPath.Values)
{
if (candidate.ProviderPath.StartsWith(prefix, StringComparison.Ordinal))
{
descendants.Add(candidate);
}
}
descendants.Sort(static (left, right) => right.ProviderPath.Length.CompareTo(left.ProviderPath.Length));
foreach (MaterializedNode descendant in descendants)
{
await RemoveSingleNodeAsync(descendant, cancellationToken).ConfigureAwait(false);
}
await RemoveSingleNodeAsync(entry, cancellationToken).ConfigureAwait(false);
}
private async ValueTask RemoveSingleNodeAsync(
MaterializedNode entry,
CancellationToken cancellationToken)
{
if (entry.Registered && m_deregisterNode != null)
{
await m_deregisterNode(entry.Node, cancellationToken).ConfigureAwait(false);
}
DetachCallbacks(entry.Node);
ForgetHandle(entry.Node.NodeId);
entry.Node.Parent?.RemoveChild(entry.Node);
m_nodesByPath.Remove(entry.ProviderPath);
m_nodesById.Remove(entry.Node.NodeId);
}
private MaterializedNode AddMaterializedNode(string providerPath, BaseInstanceState node, bool isDirectory)
{
var entry = new MaterializedNode(providerPath, node, isDirectory);
m_nodesByPath[providerPath] = entry;
m_nodesById[node.NodeId] = entry;
return entry;
}
private void WireDirectoryCallbacks(FileDirectoryState directory, string providerPath)
{
EnsureDirectoryMethods(directory);
directory.DeleteFileSystemObject!.OnCallAsync = (context, method, objectId, objectToDelete, ct) =>
FileSystemDirectoryOperations.DeleteAsync(this, context, providerPath, objectToDelete, ct);
directory.CreateFile!.OnCallAsync = (context, method, objectId, fileName, requestFileOpen, ct) =>
FileSystemDirectoryOperations.CreateFileAsync(
this, context, providerPath, fileName, requestFileOpen, ct);
directory.CreateDirectory!.OnCallAsync = (context, method, objectId, directoryName, ct) =>
FileSystemDirectoryOperations.CreateDirectoryAsync(this, context, providerPath, directoryName, ct);
directory.MoveOrCopy!.OnCallAsync = (
context,
method,
objectId,
objectToMoveOrCopy,
targetDirectory,
createCopy,
newName,
ct) => FileSystemDirectoryOperations.MoveOrCopyAsync(
this, context, providerPath, objectToMoveOrCopy, targetDirectory, createCopy, newName, ct);
}
private void DetachDirectoryCallbacks(FileDirectoryState directory)
{
directory.DeleteFileSystemObject?.OnCallAsync = null;
directory.CreateFile?.OnCallAsync = null;
directory.CreateDirectory?.OnCallAsync = null;
directory.MoveOrCopy?.OnCallAsync = null;
}
private void EnsureDirectoryMethods(FileDirectoryState directory)
{
if (directory.DeleteFileSystemObject == null)
{
directory.DeleteFileSystemObject = new DeleteFileMethodState(directory);
directory.DeleteFileSystemObject.Create(
m_context,
MethodIds.FileDirectoryType_DeleteFileSystemObject,
new QualifiedName(BrowseNames.DeleteFileSystemObject),
new LocalizedText(BrowseNames.DeleteFileSystemObject), false);
}
directory.DeleteFileSystemObject.Executable = true;
directory.DeleteFileSystemObject.UserExecutable = true;
if (directory.CreateFile == null)
{
directory.CreateFile = new CreateFileMethodState(directory);
directory.CreateFile.Create(m_context, MethodIds.FileDirectoryType_CreateFile,
new QualifiedName(BrowseNames.CreateFile),
new LocalizedText(BrowseNames.CreateFile), false);
}
directory.CreateFile.Executable = true;
directory.CreateFile.UserExecutable = true;
if (directory.CreateDirectory == null)
{
directory.CreateDirectory = new CreateDirectoryMethodState(directory);
directory.CreateDirectory.Create(m_context, MethodIds.FileDirectoryType_CreateDirectory,
new QualifiedName(BrowseNames.CreateDirectory),
new LocalizedText(BrowseNames.CreateDirectory), false);
}
directory.CreateDirectory.Executable = true;
directory.CreateDirectory.UserExecutable = true;
if (directory.MoveOrCopy == null)
{
directory.MoveOrCopy = new MoveOrCopyMethodState(directory);
directory.MoveOrCopy.Create(m_context, MethodIds.FileDirectoryType_MoveOrCopy,
new QualifiedName(BrowseNames.MoveOrCopy),
new LocalizedText(BrowseNames.MoveOrCopy), false);
}
directory.MoveOrCopy.Executable = true;
directory.MoveOrCopy.UserExecutable = true;
}
private NodeId CreateMaterializedNodeId(string kind, string providerPath)
{
string escapedPath = Uri.EscapeDataString(providerPath);
return new NodeId(m_nodeIdPrefix + "/" + kind + "/" + escapedPath, Directory.NodeId.NamespaceIndex);
}
private static void DetachCallbacks(NodeState node)
{
if (node is DirectoryObjectState directory)
{
directory.DetachCallbacks();
}
else if (node is FileObjectState file)
{
file.DetachCallbacks();
}
}
private static FileDirectoryBindingOptions ValidateOptions(FileDirectoryBindingOptions options)
{
if (options.MaxEntries < 0)
{
throw new ArgumentOutOfRangeException(nameof(options), "MaxEntries must be non-negative.");
}
if (options.MaxDepth < 0)
{
throw new ArgumentOutOfRangeException(nameof(options), "MaxDepth must be non-negative.");
}
return options;
}
private readonly SemaphoreSlim m_gate = new(1, 1);
private readonly Dictionary<string, FileHandle> m_handles = new(StringComparer.Ordinal);
/// <summary>
/// Path identities of the Delete and Move mutations currently running; guarded by m_lock.
/// </summary>
private readonly List<string> m_mutations = [];
private readonly Dictionary<NodeId, MaterializedNode> m_nodesById = [];
private readonly Dictionary<string, MaterializedNode> m_nodesByPath = new(StringComparer.Ordinal);
private readonly ISystemContext m_context;
private readonly ILogger m_logger;
private readonly FileDirectoryBindingOptions m_options;
private readonly Func<NodeState, CancellationToken, ValueTask>? m_registerNode;
private readonly Func<NodeState, CancellationToken, ValueTask>? m_deregisterNode;
private readonly Lock m_lock = new();
/// <summary>Serializes lifecycle attachment without holding the handle dictionary gate.</summary>
private readonly Lock m_sessionLifecycleLock = new();
/// <summary>The lifecycle source, attached on the first open and removed on disposal.</summary>
private ISessionManager? m_sessions;
private readonly string m_nodeIdPrefix;
private Dictionary<NodeId, MaterializedNode> m_lookupById = [];
private bool m_disposed;
private bool m_initializing;
private bool m_refreshRequired;
private int m_activeOperations;
/// <summary>
/// Associates one provider entry with its address-space node and registration state.
/// </summary>
private sealed class MaterializedNode
{
/// <summary>
/// Captures the provider path, materialized node, and file-or-directory kind.
/// </summary>
public MaterializedNode(string providerPath, BaseInstanceState node, bool isDirectory)
{
ProviderPath = providerPath;
Node = node;
IsDirectory = isDirectory;
}
/// <summary>
/// Gets the provider-relative path represented by the node.
/// </summary>
public string ProviderPath { get; }
/// <summary>
/// Gets the address-space node materialized for the provider entry.
/// </summary>
public BaseInstanceState Node { get; }
/// <summary>
/// Gets whether the provider entry is a directory rather than a file.
/// </summary>
public bool IsDirectory { get; }
/// <summary>
/// Gets or sets whether node registration completed successfully.
/// </summary>
public bool Registered { get; set; }
}
}
}
/// <summary>
/// Records materialized directory reconciliation failures and their mutation context.
/// </summary>
internal static partial class FileDirectoryBinderLog
{
/// <summary>
/// Reports a failed refresh and whether its provider mutation had already committed.
/// </summary>
[LoggerMessage(EventId = ServerEventIds.FileDirectoryBinder, Level = LogLevel.Error,
Message = "File-directory {DirectoryId} refresh failed (current provider mutation committed: " +
"{MutationCommitted}). " +
"RefreshAsync or the next mutation will retry reconciliation.")]
public static partial void FileDirectoryRefreshFailed(
this ILogger logger, Exception ex, NodeId directoryId, bool mutationCommitted);
}
}