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
6 changes: 3 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@
</ItemGroup>
<ItemGroup>
<!-- Others -->
<PackageVersion Include="Nuplane.Abstractions" Version="0.0.11" />
<PackageVersion Include="Nuplane.Loading.Abstractions" Version="0.0.11" />
<PackageVersion Include="Nuplane" Version="0.0.11" />
<PackageVersion Include="Nuplane.Abstractions" Version="0.0.12" />
<PackageVersion Include="Nuplane.Loading.Abstractions" Version="0.0.12" />
<PackageVersion Include="Nuplane" Version="0.0.12" />
<PackageVersion Include="FluentStorage" Version="6.0.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
<!-- Testing -->
Expand Down
6 changes: 3 additions & 3 deletions specs/021-nuplane-adapter/contracts/CShells.Nuplane.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Dependencies

The adapter package references `CShells`, `CShells.Abstractions`, `Nuplane.Abstractions`, and `Nuplane.Loading.Abstractions`. Nuplane dependencies use central interim version `0.0.11-preview.99`. The package does not reference Nuplane runtime/loading implementations or Elsa.
The adapter package references `CShells`, `CShells.Abstractions`, `Nuplane.Abstractions`, and `Nuplane.Loading.Abstractions`. This release candidate pins the Nuplane family to `0.0.12`; stable package availability is an external prerequisite for package restore and final qualification. The package does not reference Nuplane runtime/loading implementations or Elsa.

## Feature assembly provider

Expand Down Expand Up @@ -75,9 +75,9 @@ The adapter does not format host-specific errors. A host callback may recognize

## Registration and ordering guarantees

- The adapter appends its own `INuplaneObserver` factory after the loader observer. Existing observer descriptors remain in place and are not replaced.
- The adapter appends its own `INuplaneObserver` factory after the loader observer. The coordinator implements `IPostLoadingNuplaneObserver`. Its scoped callbacks are no-ops during coordinated Phase A; it refreshes the catalog and optionally reloads shells from the awaited post-loading callback, after root admission and store-lock release and after Nuplane loads leased graphs. On the ordinary non-coordinated route, `OnPackagesReconciledAsync` retains its existing behavior. The coordinator does not claim package-path independence because its refresh reads the active package catalog.
- Root observer and root `IShellGenerationBuildParticipant` resolve one coordinator instance.
- Only the private, nonparticipant `CoordinatorHolder` is selected by `ShareSingletonWithShells<T>()`.
- The participant concrete and interface descriptors stay root-only. Shell observer aliases resolve the root coordinator through the shared holder.
- Registry resolution is lazy inside observer work through a root `Func<IShellRegistry>`; the coordinator constructor does not resolve the registry.
- The no-op build lease provides no assembly-readability or generation-lifetime protection.
- The no-op build lease provides no separate assembly-readability or generation-lifetime protection. Runtime catalog snapshots and shell providers retain loaded assemblies and feature types while needed; a serving or draining generation therefore keeps its collectible load context alive until the generation is disposed. Nuplane retains ownership of package paths and graph-use lifetime.
53 changes: 50 additions & 3 deletions src/CShells.Nuplane/Internal/NuplaneRefreshCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nuplane.Abstractions;
using Nuplane.Abstractions.PackageStoreProtection;

namespace CShells.Nuplane.Internal;

Expand All @@ -12,7 +13,7 @@ internal sealed class NuplaneRefreshCoordinator(
IRuntimeFeatureCatalog featureCatalog,
IOptionsMonitor<NuplaneIntegrationOptions> options,
Func<IShellRegistry> shellRegistryFactory,
ILogger<NuplaneRefreshCoordinator>? logger = null) : INuplaneObserver, IShellGenerationBuildParticipant
ILogger<NuplaneRefreshCoordinator>? logger = null) : IPostLoadingNuplaneObserver, IShellGenerationBuildParticipant
{
private readonly IRuntimeFeatureCatalog catalog = featureCatalog ?? throw new ArgumentNullException(nameof(featureCatalog));
private readonly IOptionsMonitor<NuplaneIntegrationOptions> optionsMonitor = options ?? throw new ArgumentNullException(nameof(options));
Expand All @@ -34,10 +35,56 @@ internal sealed class NuplaneRefreshCoordinator(

public Task OnPackageFailedAsync(string packageId, Exception exception, CancellationToken cancellationToken) => Task.CompletedTask;

public async Task OnPackagesReconciledAsync(
public Task OnPackagesReconciledAsync(
PackageChangeSet changeSet,
IReadOnlyList<ResolvedPackage> appliedPackages,
CancellationToken cancellationToken)
=> ProcessCompletionAsync(
changeSet,
appliedPackages,
nameof(INuplaneObserver.OnPackagesReconciledAsync),
cancellationToken);

public Task OnPackagesChangingAsync(
PackageChangeSet changeSet,
PackageStoreOperationBorrow borrow,
CancellationToken cancellationToken) => Task.CompletedTask;

public Task OnPackagesChangedAsync(
PackageChangeSet changeSet,
PackageStoreOperationBorrow borrow,
CancellationToken cancellationToken) => Task.CompletedTask;

public Task OnPackageFailedAsync(
string packageId,
Exception exception,
PackageStoreOperationBorrow borrow,
CancellationToken cancellationToken) => Task.CompletedTask;

/// <summary>
/// Participating reconciliation deliberately does no refresh while its root admission and store lock are held.
/// The post-loading callback runs after those locks are released and after Nuplane has loaded leased graphs.
/// </summary>
public Task OnPackagesReconciledAsync(
PackageChangeSet changeSet,
IReadOnlyList<ResolvedPackage> appliedPackages,
PackageStoreOperationBorrow borrow,
CancellationToken cancellationToken) => Task.CompletedTask;

public Task OnPackageLoadingCompletedAsync(
PackageChangeSet changeSet,
IReadOnlyList<ResolvedPackage> appliedPackages,
CancellationToken cancellationToken) => ProcessCompletionAsync(
changeSet,
appliedPackages,
nameof(IPostLoadingNuplaneObserver.OnPackageLoadingCompletedAsync),
cancellationToken);

private async Task ProcessCompletionAsync(
PackageChangeSet changeSet,
IReadOnlyList<ResolvedPackage> appliedPackages,
string operationName,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(changeSet);
ArgumentNullException.ThrowIfNull(appliedPackages);
Expand Down Expand Up @@ -88,7 +135,7 @@ public async Task OnPackagesReconciledAsync(
loggerInstance.LogError(
exception,
"Nuplane reconciliation operation {Operation} failed for {CorrelationId}.",
nameof(INuplaneObserver.OnPackagesReconciledAsync),
operationName,
changeSet.CorrelationId);
throw;
}
Expand Down
5 changes: 3 additions & 2 deletions src/CShells.Nuplane/NuplaneFeatureAssemblyProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ namespace CShells.Nuplane;
/// Contributes assemblies Nuplane has already loaded for active packages to CShells feature discovery.
/// </summary>
/// <remarks>
/// The provider returns loaded <see cref="Assembly"/> instances in package-catalog order. It does not load
/// package files or use <see cref="PackageAssemblies.AssemblyReferences"/>.
/// The provider returns loaded <see cref="Assembly"/> instances in package-catalog order. The catalog owns its
/// active/load-state and package-data lookups; this provider itself does not open package files or use
/// <see cref="PackageAssemblies.AssemblyReferences"/>.
/// </remarks>
public sealed class NuplaneFeatureAssemblyProvider(IPackageAssemblyCatalog packageAssemblyCatalog) : IFeatureAssemblyProvider
{
Expand Down
8 changes: 4 additions & 4 deletions src/CShells.Nuplane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ shells.WithNuplaneFeatureDiscovery();

`AddNuplane` accepts a configuration callback and returns `IServiceCollection`. Calling `WithNuplaneFeatureDiscovery` afterward appends the CShells adapter observer after Nuplane's autoloader and existing observer registrations. The adapter uses one root coordinator for both Nuplane observation and CShells build participation. Do not register another coordinator through `OnPackagesChanged<T>()`.

The provider queries `IPackageAssemblyCatalog` and contributes each `PackageAssemblies.Assemblies` collection in package order. These are already-loaded `Assembly` instances. The adapter does not read `AssemblyReferences`, load packages, or activate them. Adding the package alone does not replace CShells' current feature sources; the host opts in by calling `WithNuplaneFeatureDiscovery`.
The provider invokes `IPackageAssemblyCatalog` and contributes each returned `PackageAssemblies.Assemblies` collection in package order. Catalog queries may inspect active state and installed package data, and may materialize or load additional assemblies from active installs under Nuplane's coordinated admission; obtaining the returned `Assembly` instances is not a memory-only read. The CShells provider delegates those reads to the catalog and does not directly open package files or read `AssemblyReferences`, or independently manage package loading and activation. Its coordinator implements Nuplane's `IPostLoadingNuplaneObserver`: coordinated reconciliation callbacks do no catalog work while the root admission and store lock are held, then the post-loading callback refreshes the catalog and optionally reloads shells after Nuplane has loaded the leased graphs. Ordinary non-coordinated observer callbacks retain their existing refresh behavior. Adding the package alone does not replace CShells' current feature sources; the host opts in by calling `WithNuplaneFeatureDiscovery`.

## Choose refresh and reload behavior

Expand Down Expand Up @@ -61,23 +61,23 @@ shells.WithNuplaneFeatureDiscovery(options =>
});
```

An eligible completion is a delivered `OnPackagesReconciledAsync` callback with at least one successfully applied package or a committed removal. `ChangedOrPending` scans after additions, updates, removals, or a previous failed/deferred refresh. An unchanged completion with a fresh catalog and no pending reload does no work. `EveryEligibleCompletion` scans each eligible delivery, including unchanged successful applications.
An eligible completion has at least one successfully applied package or a committed removal. In coordinated reconciliation, refresh and reload run from the post-loading callback after Nuplane finishes loading leased graphs; otherwise they run from `OnPackagesReconciledAsync`. `ChangedOrPending` scans after additions, updates, removals, or a previous failed/deferred refresh. An unchanged completion with a fresh catalog and no pending reload does no work. `EveryEligibleCompletion` scans each eligible delivery, including unchanged successful applications.

`Enabled = false` suppresses new observer-driven refresh and reload requests. It does not disable the assembly provider's normal query when CShells initializes the feature catalog, and it does not discard freshness already pending for a later build. Reloading is independent: with `AutoReload = false`, a refresh updates catalog metadata while active shells keep their current generation. Disabling automatic reload pauses an already-pending failed reload; while disabled, new work does not create a reload request. A later eligible delivery with automatic reload enabled may retry retained failed reload work.

The adapter reads the current options once for each eligible reconciliation delivery and copies the four policy values before recording work. Standard `IOptionsMonitor` configuration therefore applies on the next eligible delivery, including values bound from a reloaded `IConfiguration`; changes do not start a background refresh or reload by themselves. The current delivery keeps the policy it captured even if configuration changes while its refresh is running. Build participation does not read options. Use `Configure`, `Bind`, or dependency-aware `AddOptions` configuration so the registered monitor owns the live values. A custom `IOptionsMonitor<NuplaneIntegrationOptions>` registration is supported; replacing only `IOptions<NuplaneIntegrationOptions>` is unsupported.

## Diagnose observer operation failures

When logging is registered, an ordinary nonfatal failure from admitted reconciliation work produces one adapter `Error` record with the original exception, the `PackageChangeSet.CorrelationId`, and the `OnPackagesReconciledAsync` operation name. The adapter then rethrows the same exception so Nuplane retains its observer warning and continuation behavior. Requested-token cancellation, the documented fatal exceptions, build-time refresh failures, and returned per-shell `ReloadResult.Error` values remain outside this diagnostic boundary. With no logging provider, the adapter uses `NullLogger` and preserves the same failure propagation.
When logging is registered, an ordinary nonfatal failure from an observer-driven refresh or reload produces one adapter `Error` record with the original exception, the `PackageChangeSet.CorrelationId`, and the operation name of the callback that performed the work (`OnPackagesReconciledAsync` or `OnPackageLoadingCompletedAsync`). The adapter then rethrows the same exception so Nuplane retains its observer warning and continuation behavior. Requested-token cancellation, the documented fatal exceptions, build-time refresh failures, and returned per-shell `ReloadResult.Error` values remain outside this diagnostic boundary. With no logging provider, the adapter uses `NullLogger` and preserves the same failure propagation.

As with the framework's other logging consumers, exception-preserving diagnostics assume registered logging providers do not throw from `ILogger.Log`; logger-provider failures are outside this adapter contract.

## Refresh cold shells before construction

When a package completion arrives with no active shell, the adapter records catalog freshness and returns without scanning assemblies, activating a shell, or reloading. The next requested shell build consumes pending freshness before CShells initializes or reads the feature catalog. A successful refresh is committed even if later shell initialization fails, so the next build can reuse that fresh snapshot.

Refresh requests and acknowledgements use separate epochs. A failed or cancelled refresh remains pending. A source change arriving during refresh remains outstanding after the in-flight refresh commits its captured epoch. The build participant returns a no-op lease and does not provide assembly-readability, load-context, or generation-lifetime protection.
Refresh requests and acknowledgements use separate epochs. A failed or cancelled refresh remains pending. A source change arriving during refresh remains outstanding after the in-flight refresh commits its captured epoch. The build participant returns a no-op lease; it contributes no separate lifetime protection. The runtime catalog and each shell provider retain the discovered feature types they use. After package removal, a serving generation can continue resolving its existing feature types while it drains; its references keep the collectible load context alive until the generation is disposed. Nuplane remains responsible for package paths and any graph-use lifetime attached to that load context.

## Report host-specific reload failures

Expand Down
4 changes: 2 additions & 2 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<NoWarn>$(NoWarn);CS1591</NoWarn>

<!-- Central base version for all packages; preview builds will append a suffix like -preview.{runNumber} -->
<Version>0.0.30</Version>
<Version>0.0.31</Version>

<!-- NuGet Package Metadata -->
<Authors>Sipke Schoorstra</Authors>
Expand Down Expand Up @@ -48,4 +48,4 @@
<PackageReference Include="JetBrains.Annotations"/>
</ItemGroup>

</Project>
</Project>
5 changes: 5 additions & 0 deletions tests/CShells.DynamicFeatureFixture/DynamicFeature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public sealed class DynamicFeature : IWebShellFeature
/// <inheritdoc />
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<DynamicFeatureMarker>();
}

/// <inheritdoc />
Expand All @@ -26,3 +27,7 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints, IHostEnvironment? envi

private static IResult HandleRequest() => Results.Ok("collectible-feature");
}

public sealed class DynamicFeatureMarker
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ public async Task RootAndShellResolveTheSameCoordinatorRegardlessOfFirstAlias(bo
}

Assert.Same(observer, participant);
Assert.IsAssignableFrom<IPostLoadingNuplaneObserver>(observer);
Assert.False(observer is IPackagePathIndependentNuplaneObserver);
var shell = await host.GetRequiredService<IShellRegistry>().ActivateAsync("aliases");
Assert.Same(observer, shell.ServiceProvider.GetServices<INuplaneObserver>().Last());
Assert.Null(shell.ServiceProvider.GetService<IShellGenerationBuildParticipant>());
Expand Down
Loading
Loading