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
4 changes: 4 additions & 0 deletions docs/adr/0004-bootstrap-services-outside-the-container.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ The host has its own dependency injection container.
- The bootstrap order is explicit code, and each step can degrade independently; see
[ADR 0005](0005-policy-failures-stop-startup.md).
- Tests build a `BootstrapContext` from fakes without starting the host.
- Single-instance arbitration runs before the bootstrap, so a secondary process creates none of these services. The
pipe server it claims is not a bootstrap service: `Program.Main` owns it and the host container receives it as an
existing instance, like the bootstrap services; see
[architecture](../design/architecture.md#single-instance-contract).
34 changes: 22 additions & 12 deletions docs/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

## Composition and resource ownership

[BravoApplication.Run](../../src/Host/BravoApplication.cs) resolves the single-instance role before starting
Kestrel: a secondary process redirects its activation and exits without starting a server or a window. It does so
after the bootstrap services and the host are built, so a secondary process still pays for both.
[Program.Main](../../src/Program.cs) resolves the single-instance role right after installing the exception handler,
before every other startup step; see the [single-instance contract](#single-instance-contract).

| Resource | Lifetime owner | Constraint for changes |
| --- | --- | --- |
| Pipe server that holds the application instance | `Program.Main`, which claims it through [BravoSingleInstance](../../src/Host/BravoSingleInstance.cs) and disposes it last; the container receives it as an existing instance | Releasing the pipe ends ownership of the application instance and lets the next process become the primary. Releasing it before the bootstrap services are disposed lets two processes write the same user data |
| Activation events | The host container owns [BravoApplicationInstance](../../src/Host/BravoApplicationInstance.cs), registered as `IInstanceEvents` | Its disposal only unsubscribes from the pipe server; it must not release the pipe |
| Bootstrap telemetry and logger factory | [BootstrapContext](../../src/Host/BootstrapContext.cs) | Keep them available until host disposal completes; the exception handler is detached first, then telemetry is disposed before the logger factory |
| Bootstrap policy service and user settings | Bootstrap creates or obtains them; the container receives those instances | Preserve the shared instances so consumers do not silently use different configuration snapshots |
| Application instance and its pipe server | The host container owns `IBravoApplicationInstance`; that instance owns the pipe server | Releasing the pipe ends ownership of the application instance |
| Main window | `BravoApplication.Run` | The window is created through DI but is not registered as a container-owned service |

[BravoServiceCollectionExtensions](../../src/Host/BravoServiceCollectionExtensions.cs) registers the bootstrap
services as existing instances, which the container does not dispose. Replacing them with container-owned factories
moves disposal to the container and must keep the order above. Global exception handling uses the bootstrap
services, not the container; its coverage is in [diagnostics](diagnostics.md#startup-coverage-and-fatal-reporting).
services and the pipe server as existing instances, which the container does not dispose. Replacing them with
container-owned factories moves disposal to the container and must keep the order above. Global exception handling
uses the bootstrap services, not the container; its coverage is in
[diagnostics](diagnostics.md#startup-coverage-and-fatal-reporting).
Why the bootstrap services live outside the container:
[ADR 0004](../adr/0004-bootstrap-services-outside-the-container.md).

Expand Down Expand Up @@ -72,13 +73,22 @@ stable across releases: changing them allows versions that share user data to ru
through a named pipe with one server instance. It disconnects between clients without releasing the pipe name. Do
not replace that with disposal and recreation between requests: another process could claim ownership in the gap.

Current activation limits in [BravoApplicationInstance](../../src/Host/BravoApplicationInstance.cs):
`BravoSingleInstance.ClaimOrRedirect` claims the pipe before the bootstrap: a secondary process forwards its startup
arguments and exits without creating directories, bootstrap services, a host or a window, and without reading the
user settings file. Keep the arbitration and the activation payload free of those steps: a secondary that builds
bootstrap services can transmit stored telemetry and reads files that the primary writes.

- A delivered payload does not guarantee that the requested document opened. Requests arriving before the window
or WebView subscriptions are ready are not buffered.
Current activation limits:

- A delivered payload does not guarantee that the requested document opened. Requests are not buffered: the pipe
server drops those that arrive before the host creates the `BravoApplicationInstance` that subscribes to it, and
the window drops those that arrive before its subscriptions are ready. A request that arrives while the primary
shuts down is lost as well, because the pipe is held until the bootstrap services are disposed.
- Activation callbacks can run concurrently and in a different order from arrival.
- Different elevation levels still share an instance name, but pipe access can prevent activation redirection. A
redirect failure is logged and tracked without a user-facing dialog.
- Different elevation levels still share an instance name, but pipe access can prevent activation redirection. The
secondary logs a redirect failure to the Event Log through a logger factory that has only that provider, and exits
without a user-facing dialog. It tracks no telemetry: consent needs the policies and settings that the secondary
does not create.

## Configuration boundaries

Expand Down
10 changes: 6 additions & 4 deletions docs/design/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ ends the process: [ADR 0003](../adr/0003-fatal-exceptions-end-the-process.md).
enables the visual styles the task dialog needs, and before every other startup step. Logging and telemetry do not
exist yet: until `BootstrapContextFactory` attaches them to the handler, a fatal exception is
saved to the report and shown in the dialog, without a log entry from Bravo, without telemetry and without the Send
button. The runtime still writes its own Event Log entry when it ends the process. This covers directory and proxy
setup, the WebView2 runtime check and bootstrap creation. `BootstrapContext` detaches the handler before it disposes
the services, so an exception raised during that disposal is reported the same way and never through disposed
services.
button. The runtime still writes its own Event Log entry when it ends the process. This covers single-instance
arbitration, directory and proxy setup, the WebView2 runtime check and bootstrap creation. A secondary instance exits
after the arbitration, so the whole process is covered this way
([single-instance contract](architecture.md#single-instance-contract)). `BootstrapContext` detaches the handler
before it disposes the services, so an exception raised during that disposal is reported the same way and never
through disposed services.
[BootstrapContextFactory](../../src/Host/BootstrapContextFactory.cs) degrades to default settings and disabled
telemetry when they cannot be created, but a policy read failure propagates on purpose, so that administrative
restrictions are never bypassed: a policy registry key that cannot be read ends the process through this path
Expand Down
4 changes: 1 addition & 3 deletions src/Host/BootstrapContextFactory.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.EventLog;
using Sqlbi.Bravo.Infrastructure.Configuration;
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Diagnostics;
Expand Down Expand Up @@ -64,8 +63,7 @@ private static ILoggerFactory CreateLoggerFactory()
return LoggerFactory.Create((builder) =>
{
builder.AddEventSourceLogger();
builder.AddEventLog();
builder.AddFilter<EventLogLoggerProvider>((level) => level >= LogLevel.Warning);
builder.AddBravoEventLog();
builder.AddDiagnosticMessages();
builder.AddFilter<DiagnosticMessageLoggerProvider>((level) => level >= LogLevel.Warning);
#if DEBUG
Expand Down
12 changes: 3 additions & 9 deletions src/Host/BravoApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Sqlbi.Bravo.Infrastructure;
using Sqlbi.Bravo.Infrastructure.SingleInstance;

namespace Sqlbi.Bravo.Host;

Expand All @@ -14,9 +15,9 @@ internal sealed class BravoApplication : IDisposable
{
private readonly WebApplication _innerApplication;

public static BravoApplicationBuilder CreateBuilder(BootstrapContext bootstrap)
public static BravoApplicationBuilder CreateBuilder(BootstrapContext bootstrap, SingleInstanceServer instanceServer)
{
return new BravoApplicationBuilder(bootstrap);
return new BravoApplicationBuilder(bootstrap, instanceServer);
}

internal BravoApplication(WebApplication innerApplication)
Expand All @@ -31,13 +32,6 @@ internal BravoApplication(WebApplication innerApplication)
/// </summary>
public void Run()
{
var instance = Services.GetRequiredService<IBravoApplicationInstance>();
if (!instance.IsPrimary)
{
instance.RedirectActivationToPrimary();
return;
}

_innerApplication.Start();
try
{
Expand Down
9 changes: 5 additions & 4 deletions src/Host/BravoApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.Extensions.Hosting;
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Services;
using Sqlbi.Bravo.Infrastructure.SingleInstance;

namespace Sqlbi.Bravo.Host;

Expand All @@ -17,7 +18,7 @@ internal sealed class BravoApplicationBuilder
{
private readonly WebApplicationBuilder _innerBuilder;

internal BravoApplicationBuilder(BootstrapContext bootstrap)
internal BravoApplicationBuilder(BootstrapContext bootstrap, SingleInstanceServer instanceServer)
{
// CreateEmptyBuilder, not CreateBuilder: no configuration sources, no environment variables and
// no implicit logging providers. The logging pipeline comes from the bootstrap context, everything
Expand All @@ -29,7 +30,7 @@ internal BravoApplicationBuilder(BootstrapContext bootstrap)
});

ConfigureHosting(_innerBuilder);
ConfigureServices(_innerBuilder, bootstrap);
ConfigureServices(_innerBuilder, bootstrap, instanceServer);
}

internal IServiceCollection Services => _innerBuilder.Services;
Expand Down Expand Up @@ -69,14 +70,14 @@ private static void ConfigureHosting(WebApplicationBuilder builder)
#endif
}

private static void ConfigureServices(WebApplicationBuilder builder, BootstrapContext bootstrap)
private static void ConfigureServices(WebApplicationBuilder builder, BootstrapContext bootstrap, SingleInstanceServer instanceServer)
{
builder.Services.AddOptions<StartupSettings>()
.Configure((settings) => settings.FromCommandLineArguments()); //.ValidateDataAnnotations();
builder.Services.AddSingleton<IServerAddressProvider, ServerAddressProvider>();

builder.Services.AddBravoRestApi();
builder.Services.AddBravoServices(bootstrap);
builder.Services.AddBravoServices(bootstrap, instanceServer);
}

private static void ConfigurePipeline(WebApplication application)
Expand Down
4 changes: 0 additions & 4 deletions src/Host/BravoApplicationInstance.Log.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using Microsoft.Extensions.Logging;
using Sqlbi.Bravo.Infrastructure.SingleInstance;

namespace Sqlbi.Bravo.Host;

Expand All @@ -9,9 +8,6 @@ internal sealed partial class BravoApplicationInstance
[LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "The single-instance listener reported a failure and keeps listening.")]
private partial void LogListenerFailed(Exception exception);

[LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "The activation could not be redirected to the primary instance ({Status}).")]
private partial void LogActivationRedirectFailed(Exception exception, SingleInstanceSendStatus status);

[LoggerMessage(EventId = 3, Level = LogLevel.Warning, Message = "The activation payload could not be read; the window is activated without a document.")]
private partial void LogActivationPayloadUnreadable(Exception exception);
}
85 changes: 14 additions & 71 deletions src/Host/BravoApplicationInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,38 @@
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Messages;
using Sqlbi.Bravo.Infrastructure.SingleInstance;
using Sqlbi.Bravo.Infrastructure.Telemetry;

namespace Sqlbi.Bravo.Host;

/// <summary>
/// Defines the contract for a single-instance application.
/// Turns the payloads received by the primary instance into activation requests.
/// </summary>
internal interface IBravoApplicationInstance : IInstanceEvents, IDisposable
/// <remarks>
/// The server is owned by <c>Program.Main</c>, which claims it through <see cref="BravoSingleInstance"/>: disposing
/// this instance stops the events but keeps the pipe.
/// </remarks>
internal sealed partial class BravoApplicationInstance : IInstanceEvents, IDisposable
{
/// <summary>
/// Gets a value indicating whether this process is the primary instance, the one that runs the application.
/// </summary>
bool IsPrimary { get; }

/// <summary>
/// Redirects this process's activation to the primary instance, forwarding its startup arguments.
/// </summary>
void RedirectActivationToPrimary();
}

internal sealed partial class BravoApplicationInstance : IBravoApplicationInstance
{
private readonly SingleInstanceOptions _options;
private readonly SingleInstanceServer? _server;
private readonly SingleInstanceServer _server;
private readonly ITelemetryService _telemetry;
private readonly ILogger _logger;

private bool _disposed;

public BravoApplicationInstance(
SingleInstanceOptions options,
SingleInstanceServer? server,
SingleInstanceServer server,
ITelemetryService telemetry,
ILoggerFactory loggerFactory)
{
_options = options;
_server = server;
_telemetry = telemetry;
_logger = loggerFactory.CreateLogger<BravoApplicationInstance>();

if (_server is not null)
{
_server.Activated += OnActivated;
_server.Error += OnError;
}
_server.Activated += OnActivated;
_server.Error += OnError;
}

/// <inheritdoc/>
public bool IsPrimary => _server is not null;

/// <inheritdoc/>
public event EventHandler<InstanceActivationRequestedEventArgs>? ActivationRequested;

Expand All @@ -72,13 +51,8 @@ private void OnActivated(object? sender, SingleInstanceActivatedEventArgs e)
_telemetry.TrackException(ex);
}

// Known limitation: requests arriving while this instance is still starting up are dropped
// here. The pipe answers from the moment the process starts, but the subscribers appear
// later: AppWindow attaches the bring-to-front handler in OnLoad, and the one that forwards
// the startup message only in OnWebViewDOMContentLoaded, seconds later. A request that lands
// before the window exists is lost, and one that lands between the window and a loaded
// WebView brings Bravo to the front but does not open what was asked for. The secondary
// instance is told the payload was delivered either way.
// Requests are not buffered: those that arrive before the subscribers exist are lost; see the activation
// limits in docs/design/architecture.md.
// TODO: Consider buffering the requests and replaying them once the UI is ready.
ActivationRequested?.Invoke(this, new InstanceActivationRequestedEventArgs(startupMessage));
}
Expand All @@ -91,40 +65,9 @@ private void OnError(object? sender, ErrorEventArgs e)
_telemetry.TrackException(exception);
}

/// <inheritdoc/>
public void RedirectActivationToPrimary()
{
var startupSettings = StartupSettings.CreateFromCommandLineArguments();
var startupMessage = AppInstanceStartupMessage.CreateFrom(startupSettings);
var payload = JsonSerializer.SerializeToUtf8Bytes(startupMessage);

var result = SingleInstanceClient.Send(_options, payload);
if (!result.IsDelivered)
{
// The primary instance cannot be reached and activation fails silently. One known cause is
// a launch whose elevation differs from the primary's: PipeOptions.CurrentUserOnly builds
// and checks the pipe ACL on WindowsIdentity.Owner, which elevation changes to
// BUILTIN\Administrators, while the pipe name is keyed on WindowsIdentity.User
// (https://github.com/dotnet/runtime/issues/123903, open, milestone .NET 12).
// TODO: Consider surfacing activation failures to the user.
LogActivationRedirectFailed(result.Exception, result.Status);
_telemetry.TrackException(result.Exception);
}
}

/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
return;

_disposed = true;

if (_server is not null)
{
_server.Activated -= OnActivated;
_server.Error -= OnError;
_server.Dispose();
}
_server.Activated -= OnActivated;
_server.Error -= OnError;
}
}
16 changes: 16 additions & 0 deletions src/Host/BravoLoggingBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.EventLog;

namespace Sqlbi.Bravo.Host;

internal static class BravoLoggingBuilderExtensions
{
// Shared by the primary and the secondary instance, so that both write to the Event Log with the same source and threshold.
public static ILoggingBuilder AddBravoEventLog(this ILoggingBuilder builder)
{
builder.AddEventLog();
builder.AddFilter<EventLogLoggerProvider>((level) => level >= LogLevel.Warning);

return builder;
}
}
Loading
Loading