Beginning our journey with .NET Core

This week we hit the first major milestone on our journey towards a cross-platform Seq. It's been on the radar for several years, but over the last two months we've done the work of retargeting Seq from the full .NET Framework to to .NET Core and .NET Standard.

This post looks at some specific details of our experience taking a reasonably large, Windows-specific codebase and adding first-class support for Linux and macOS. Overall, we've found .NET Core has been great to work with. The key APIs are now available, although ecosystem support is still a bit patchy, and the tooling is productive.

Environment

An important part of making Seq work on macOS and Linux has been making our development and continuous integration environments also work on macOS and Linux.

Development

The first step we took in the move to cross-platform was to start actively developing Seq on Linux and macOS in addition to Windows. csproj files were converted, source was #if'd out until the solution would build. We then reworked the ignored source in a piecemeal fashion until the server could actually stand up.

At Datalust, we've all taken to JetBrains Rider as our IDE of choice because it's consistent across the gamut of new development environments and very productive to work in. Not having C# Interactive available cross-platform (since csi isn't distributed with the .NET SDK as of writing) is a bit of a shame. That small issue aside, writing C# on macOS or Linux can be just as productive as it is on Windows.

Continuous integration

Seq's continuous integration got a similar treatment to our development environments with the addition of more platforms. We use AppVeyor, Travis CI and Docker Cloud to build, test and package the Seq binaries and Docker images. AppVeyor is our primary CI workflow that takes care of packaging the Windows MSI, tarballs for Linux and macOS, and triggering a Docker Cloud build. Travis builds and runs our test suite on non-Windows platforms.

Being able to target multiple platforms in a single environment with .NET is a great feature. In unmanaged languages, cross compilation can be tricky if it depends on features of the target environment in order to compile code. Being a managed language, the platform abstraction in .NET is the Common Language Runtime. That makes it easy for us to build self-contained binaries for Linux and MacOS on a typical Windows machine.

ASP.NET Core

As part of the move to .NET Core, we also migrated from Nancy to ASP.NET Core. Nancy has served us well over the last 4 years. ASP.NET Core has a stable .NET Standard release and brings some improvements in managing API endpoints in a more declarative way. Seq has a lot of its own infrastructure around building responses and hypermedia so moving from Nancy to ASP.NET Core was mostly mechanical.

One point of contention was with the WebHostBuilder API. ASP.NET Core is now totally wired together using dependency injection, which offers users a lot of flexibility in configuring their host. ASP.NET Core wants to encapsulate your container instead of your container encapsulating ASP.NET Core, and to control it through a standardised interface. This can be tricky to work with when ASP.NET Core isn't the sole consumer of services in your container, or if you're using more specific container features like Autofac's tagged lifetime scopes.

Seq already has an Autofac container configured by the time it needs to bootstrap the web host, so we had to rejig the startup a bit to create a separate container just for ASP.NET Core. We found the nicest way to work with WebHostBuilder was by implementing IStartup and registering our implementation as a singleton in the IServiceProvider, rather than using the convention-based class methods or fluent APIs.

With ASP.NET Core owning and managing the Autofac container through a standard interface, there's no longer a lifetime tag that's associated with a single request. To ensure that short-lived instances resolved from the container are actually short lived (that they're tied to a child scope or specific scope instead of the root) we use a simple NonRootScopeLifetime type that implements IComponentLifetime:

class NonRootScopeLifetime : IComponentLifetime
{
    public ISharingLifetimeScope FindScope(ISharingLifetimeScope mostNestedVisibleScope)
    {
        if (mostNestedVisibleScope.ParentLifetimeScope == null) throw new InvalidOperationException("Trying to resolve scoped service from root container.");
        if (mostNestedVisibleScope.ParentLifetimeScope.ParentLifetimeScope != null) throw new InvalidOperationException("This might be ambiguous.");

        return mostNestedVisibleScope;
    }
}

The NonRootScopeLifetime will throw if it's not within a child scope. That way if an explicitly short lived dependency is resolved for an explicitly long lived one we end up with exceptions instead of captive dependencies:

var registration = builder.Register(c => c.Resolve<IStore>().BeginSession()).As<ISession>();

registration.RegistrationData.Lifetime = new NonRootScopeLifetime();

NuGet

On Windows, Seq has traditionally used Nuget.Core to interact with nuget.org to find and install app packages. Unfortunately, some of the older NuGet tooling is still based on the full .NET Framework, so it can't be used in our netcoreapp only solution. Our demands on NuGet are very simple though; we only need to find the metadata for the latest version of a package by id, and to get nupkgs; so to work around this we've written a few very simple NuGet clients:

  • A filesystem-based client
  • A V2 odata client
  • A V3 json client

Packages are extracted simply using System.IO.Packaging, which is also a bit more lightweight than NuGet's package extraction.

Platform specifics

There were a few (surprisingly few!) platform-specific quirks we came across during the initial port. These are isolated to a few tasks that interact with OS features, like collecting system details and the filesystem.

Organisation

Platform specific code in Seq is switched using build-time pre-processor directives tied to RIDs. This approach has the benefit of being easy to reason about and available across the MSBuild tooling, so we can conditionally include packages as well as conditionally compile sources consistently.

Platform specific implementations are wrapped in a facade that exposes a common API:

As an example, Seq collects some broad system utilisation figures and uses them to make decisions about what to keep in cache. Different platforms expose this information in different ways, which are abstracted in Seq by a common PlatformMemory interface:

A disadvantage of build-time directives over runtime checks is the tooling around pre-processor blocks in Rider doesn't offer much visibility into inactive pre-processor branches. It's easy to make mistakes while refactoring, but CI is there to pick up the slack. Thankfully, we don't actually need a lot of co-existing platform specific code.

Service hosting

On Windows, Seq is typically managed as a background Windows Service. .NET Standard doesn't offer the APIs Seq needs to manage Windows Services. That's not unreasonable though, because Windows Services are a non-portable way to manage a process running in the background. In Seq we now use a combination of the Microsoft.Windows.Compatibility package and hand-rolled calls to sc.exe to manage the service. Having a nice abstraction over executing child processes and capturing their output makes it easier to interact with the OS through processes rather than direct system calls on colder paths.

Graceful shutdown

On Windows, Seq listens to Ctrl + C for processes attached to a terminal, and the Windows Service specific mechanisms for processes running as a service.

On Unix, ProcessExit will get triggered when a process or its parent is sent a SIGTERM, which also happens when calling docker stop. Seq now listens to a terminal Ctrl + C input and the AppDomain.CurrentDomain.ProcessExit event to execute a graceful shutdown.

Seq also has a shared worker pool that tracks tasks like executing queries that can be instructed to terminate. You can't rely on graceful shutdown for consistency, because processes can be brutally clobbered at any moment, but it's good to respond appropriately to signals from the OS.

DPAPI

Windows has system calls for machine and user level encryption that Seq uses to protect sensitive data. There aren't really similar portable APIs on Linux or MacOS that could be used instead. Right now we leave this data in plain-text on non-Windows platforms, or expect it to be passed in through the environment from a source that could be stored securely at rest.

Case sensitivity

The Windows environment treats files as case insensitive, whereas common Linux filesystems are case sensitive. When searching through its directories for files, Seq will assume that files are case insensitive. This involves some adjusted calls to Directory.GetFiles.

Default storage location

On Windows, Seq stores its data in the CommonApplicationData environment folder (in C:\ProgramData) by default. CommonApplicationData isn't necessarily writable by a non-root user on Windows, Linux or macOS. That's not a problem for Seq running in a Windows Service as a system account, but services requiring privileged access is usually frowned upon. As a fresh start, non-Windows platforms use LocalApplicationData (in ~/.local/share) instead as the default location for Seq data. It's a user-specific location instead of a shared one that the current user can write to.

What's next?

We're still early in our cross-platform journey. Now that we have a solid cross-platform server as a base we can start iterating towards a supported release for all platforms!