Bringing .NET Remoting and AppDomains Back to .NET 8 and 10

Leave a Comment

You have most likely encountered this obstacle if you have ever attempted to convert a long-running.NET Framework program to.NET 8 or.NET10. The code is compiled. Channel Services.In the IDE, RegisterChannel is located directly. AppDomain.The build is green, CreateDomain resolves, IntelliSense is satisfied, and when you run it for the first time, you get:

 

System.PlatformNotSupportedException: Remoting is not supported on this platform.

.NET Core removed the implementations of remoting and application domains but kept some of the type names, which produces exactly this experience: a clean compile followed by a runtime failure. The usual advice is "rewrite it as gRPC" or "rewrite it as WCF Core", and for a small surface that is fine. For an application with two hundred MarshalByRefObject-derived types, server-to-client callbacks, lifetime leases, and a plugin loader built on AppDomain.CreateDomain, "rewrite it" is a project, not a migration.

This article describes a different approach: keep the API and replace what is underneath it. I will walk through two libraries that do this — a remoting compatibility layer and a process-backed AppDomain replacement — the design constraints that shaped them, and the places where the platform simply does not allow an exact match.

The packages multi-target .NET Standard 2.0.NET 8 and .NET 10, and the whole solution — libraries, tests and samples — is built and verified against both modern runtimes.

What Actually Has to Be Replaced

Classic remoting is three things stacked together, and each one is unavailable for a different reason.

Layer.NET FrameworkWhy it is gone
ProxiesCLR transparent proxies (RealProxy)The runtime hook does not exist off .NET Framework
SerializerBinaryFormatterRemoved from the supported surface; throws outright on .NET 9+
TransportTcpChannel (SSPI-secured)The channel plumbing was never ported

So the replacement needs a proxy mechanism, a [Serializable]-aware serializer that is not BinaryFormatter, and a transport. What it does not need to change is the public API — and that is the whole point of the exercise. MarshalByRefObjectRemotingConfigurationChannelServicesObjRefILeaseISponsor and the rest keep their names and signatures.

Part 1: Remoting
Before and After

Here is a classic server registration on .NET Framework:

ChannelServices.RegisterChannel(new TcpChannel(9000), false);
RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(OrderService), "orders.rem", WellKnownObjectMode.Singleton);

And the same thing running on .NET 8 or .NET 10:

using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Tcp;

ChannelServices.RegisterChannel(new TcpServerChannel("app", 9000), ensureSecurity: false);
RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(OrderService), "orders.rem", WellKnownObjectMode.Singleton);

The channel type is named TcpServerChannel rather than TcpChannel — separate client and server channel types were always available in classic remoting, and using them makes the direction explicit. The client side:

ChannelServices.RegisterChannel(new TcpClientChannel(), ensureSecurity: false);
var orders = (IOrderService)RemotingServices.Connect(
    typeof(IOrderService), "tcp://server:9000/orders.rem");

RemotingConfiguration.Configure("app.config") still reads a <system.runtime.remoting> section, so if your deployment is configuration-driven rather than code-driven, that keeps working untouched.

A Working Contract

Let me use the sample from the repository, because it exercises the features that usually decide whether a compatibility layer is real or a demo. The contract assembly:

[Serializable]
public class Quote
{
    public string Symbol;
    public decimal Price;
    public DateTime AsOf;
    private string _source = "unset";        // private field: must survive the wire

    public string Source => _source;
    public void SetSource(string s) => _source = s;
}

// The client implements this and hands it to the server; the server calls back into it.public interface ITicker
{
    void OnQuote(Quote quote);
    void OnClosed(string reason);
}

public interface IMarketService
{
    Quote GetQuote(string symbol);
    int Subscribe(ITicker ticker, string symbol);
    void PublishTo(int subscriptionId, decimal price);
    bool TryGetLast(string symbol, out Quote quote);     // out parameter
    void Accumulate(ref decimal running, decimal delta); // ref parameter
    T Echo<T>(T value);                                  // generic method
    void Boom(string message);                           // exception marshalling
    string WhoAmI();
}

The server implementation is ordinary code:

public class MarketService : MarshalByRefObject, IMarketService
{
    private readonly Dictionary<int, (ITicker Ticker, string Symbol)> _subscriptions = new();
    private static int _nextSubscription = 1;

    public int Subscribe(ITicker ticker, string symbol)
    {
        // `ticker` arrived as an ObjRef pointing back at the client. Calling it sends a request
        // in the reverse direction over the connection the client already opened.
        var id = _nextSubscription++;
        _subscriptions[id] = (ticker, symbol);
        ticker.OnQuote(GetQuote(symbol));
        return id;
    }

    public T Echo<T>(T value) => value;

    public void Boom(string message) => throw new InvalidOperationException(message);

    public string WhoAmI() => "server-pid-" + Environment.ProcessId;
}

That WhoAmI is not decoration. It is how you prove a remoting layer is actually remoting: the client asserts that the process id it gets back is not its own. A layer that quietly short-circuits to an in-process call would pass every other test in the suite.

The Callback Improvement

This is the part where the replacement is better than the original, so it is worth dwelling on.

In classic remoting, if you wanted the server to call back into the client, the client had to register its own receiver channel and be externally addressable. That is a firewall problem, a NAT problem, and in a container it is often simply impossible.

Here, the client registers a client channel only:

// A client channel only. Note there is no server channel here at all — callbacks still work.
ChannelServices.RegisterChannel(new TcpClientChannel("sample-client", null), ensureSecurity: false);

var market = (IMarketService)RemotingServices.Connect(typeof(IMarketService), url);

var ticker = new Ticker();                              // : MarshalByRefObject, ITickervar subscription = market.Subscribe(ticker, "ACME");    // server now holds a reference to us

market.PublishTo(subscription, 123.75m);                // server pushes to us
Console.WriteLine(ticker.Quotes[^1].Price);             // 123.75

No listening port on the client. This works because the connection is framed, duplex and multiplexed: one TCP connection carries calls in both directions, correlated by request id. When the server invokes ticker.OnQuote(...), the reverse call travels back down the connection the client already opened.

The routing rule that makes this safe is worth stating precisely, because the naive version is wrong. A client with no receiver channel marshals its objects under a bare uri with no scheme — there is nothing to dial. So ConnectionContext publishes the connection a call arrived on for the duration of dispatch, and a reference with no dialable url binds its calls back to that connection. A reference that does carry a channel url is dialed normally. That distinction is what prevents a third-party reference — A hands B a reference to C — from being misrouted through the A↔B connection.

The Serializer

BinaryFormatter is not an option, so the layer carries its own [Serializable]-aware binary serializer. It honours the full classic contract, which is more than most people remember is in there:

  • private fields and [NonSerialized]

  • ISerializable with GetObjectData / the deserialization constructor

  • IObjectReferenceIDeserializationCallback, and the [OnSerializing] / [OnDeserialized] family

  • serialization surrogates

  • object cycles and shared references (identity preserved, not duplicated)

  • arrays: single-dimension, jagged, multi-dimensional, and non-zero lower bound

Two constraints are new, and both exist because the sender should not get to decide how much work the receiver does:

RemotingConfiguration.MaxGraphDepth  = 128;         // default
RemotingConfiguration.MaxFrameLength = 64 * 1024 * 1024;

The depth bound is not paranoia about malice alone. Reading a graph is recursive, so graph depth is stack depth — 50,000 nested arrays fit in 250 KB of payload and overflow the reader's stack, which you cannot catch and which takes the process with it. Declared element counts are separately checked against the bytes actually remaining in the payload: every element costs at least one byte, so a 10-byte message claiming 100,000,000 elements is lying, and the check catches it before the allocation.

Both bounds are enforced on the writing side too, so a graph that could never be read fails on the side that can still do something about it, with an error message naming the property to raise.

Deserialization Safety

TypeFilterLevel.Low is the default for a network endpoint, matching .NET Framework. There is one deliberate improvement: a deny list that applies at every level, including Full. .NET Framework's Full had no deny list at all.

The deny list names known gadget-chain entry points — types whose deserialization turns into code execution — and matches on the base chain, not the type's own name. That detail matters: matching by name alone made the System.IO.FileSystemInfo entry inert, because it is abstract and only its differently-named subclasses can be constructed.

Two related rules:

  • Delegates are refused on the general object path. A delegate reached through an object graph is a classic gadget step. There is a dedicated delegate record where the shape is known and the receiver decides whether to invoke; a static delegate is only reconstructed at TypeFilterLevel.Full.

  • Type names on an incoming message resolve only against already-loaded assemblies. Type.GetType loads assemblies by name, so resolving a wire-supplied name would let a caller choose which assembly the server loads — and run its module initializer — before dispatch. Nothing legitimate is lost: for the server to hold an object satisfying a contract, that contract's assembly is loaded by definition.

TLS

Classic remoting's secure="true" on the TCP channel meant SSPI/Negotiate — Windows authentication, not transport encryption. There is no portable equivalent, so the encryption story here is deliberately different and deliberately named differently:

server.Security = TcpChannelSecurity.ForServer(certificate);
client.Security = TcpChannelSecurity.ForClient("service.example.com");

A client that cannot chain the certificate — a self-signed cert in a closed deployment — opts out with ForClientWithoutValidation(). That is a named setting rather than a validation callback returning true, because the callback-returning-true is the single most common way TLS gets silently disabled in production.

For same-machine communication there is also an IpcChannel speaking the same framed protocol over named pipes, with ipc://portName/objectUri urls. No port means nothing is reachable from off the machine and nothing can collide with another process binding the same port.

Part 2: Application Domains
A Domain Is Now a Process

AppDomain.CreateDomain cannot be revived. The CLR has no second domain to create — this is not a missing API, it is a missing runtime feature. So a domain becomes an operating system process, and the remoting layer above carries the calls.

// Before (.NET Framework)var domain = AppDomain.CreateDomain("plugin");
var plugin = (IPlugin)domain.CreateInstanceAndUnwrap("Acme.Plugin", "Acme.Plugin.Entry");
plugin.Run();
AppDomain.Unload(domain);

// Aftervar domain = AppDomain.CurrentDomain.CreateChildDomain("plugin");
var plugin = (IPlugin)domain.CreateInstanceAndUnwrap("Acme.Plugin", "Acme.Plugin.Entry");
plugin.Run();
domain.Unload();

Only the two statics change, and only because C# has no static extension methods. SetData / GetDataDoCallBackFriendlyNameBaseDirectoryIsDefaultAppDomain() and CreateInstanceAndUnwrap keep their names and signatures.

Static State, Isolated — the Original Reason Domains Existed

using var first  = AppDomain.CurrentDomain.CreateChildDomain("tenant-a");
using var second = AppDomain.CurrentDomain.CreateChildDomain("tenant-b");

var a = first.CreateInstanceAndUnwrap<Plugin>(new object[] { "tenant-a" });
var b = second.CreateInstanceAndUnwrap<Plugin>(new object[] { "tenant-b" });

a.Execute("one");
a.Execute("two");
b.Execute("one");

// Plugin holds a private static int _callsInThisDomain
Console.WriteLine(a.Summarise().Calls);   // 2
Console.WriteLine(b.Summarise().Calls);   // 1
Console.WriteLine(a.ProcessId != b.ProcessId);   // True
Isolation That the Original Could Not Deliver

This is where a process beats a domain outright. A .NET Framework application domain shared a process, so a stack overflow, a corrupt native heap or an Environment.FailFast in the plugin killed the host along with it. Domains offered code isolation, never fault isolation.

var domain = AppDomain.CurrentDomain.CreateChildDomain("doomed");
var plugin = domain.CreateInstanceAndUnwrap<Plugin>();

try { plugin.Crash(); }              // calls Environment.FailFast inside the childcatch (Exception) { /* the connection dies with the process; that is the point */ }

while (domain.IsAlive) Thread.Sleep(50);

try { domain.CreateInstanceAndUnwrap<Plugin>(); }
catch (AppDomainUnloadedException) { /* expected */ }

// ...and this application is still running.using var healthy = AppDomain.CurrentDomain.CreateChildDomain("recovered");

The parent observes AppDomainUnloadedException — the same exception classic code already handled — and carries on. Nothing in the calling code needs to know that the mechanism changed.

DoCallBack, and Why a Lambda Cannot Work
public static class Settings
{
    // Target of a DoCallBack. Must be static — a closure cannot cross the boundary.
    public static void ApplyDefaults()
    {
        Write("mode", "configured-by-callback");
        Write("pid", Process.GetCurrentProcess().Id.ToString());
    }
}

domain.DoCallBack(Settings.ApplyDefaults);      // runs INSIDE the domain

A lambda that captures local state compiles to a closure object which has no identity the child process can reach, so it is rejected with an explanation rather than silently misbehaving:

var captured = 1;
domain.DoCallBack(() => captured++);   // throws, message mentions MarshalByRefObject
The Complete List of Required Source Changes

This is the part of any compatibility layer that decides whether it is usable. Here it is exhaustive.

1. Members on a class contract must be virtual

The CLR's transparent proxy intercepted every member. A generated proxy is a subclass, and a subclass can only override virtual members. A non-virtual one would run locally on the caller and silently return wrong results:

public class OrderService : MarshalByRefObject
{
    public virtual Order Get(int id) { ... }   // add 'virtual'
}

Two non-obvious points. First, IsVirtual is not the test — an implicit interface implementation is compiled virtual final, which is just as un-overridable, so it is reported too. Second, remoting an interface avoids the issue entirely, because interface slots are separately re-implemented and are therefore always intercepted. An interface-typed contract is the recommended shape.

Rather than allow a silent wrong answer, proxy creation fails at creation time and names every offender. A non-overridable member cannot even be guarded: DefineMethodOverride against a non-virtual method fails at CreateTypeInfo() with TypeLoadException, and a new member is never reached through a base-typed reference. Failing loudly at the earliest possible point is the only honest option.

2. Client-activated objects need an explicit factory

new Foo() was routed through activation by the CLR, and there is no hook for that off .NET Framework:

var session = RemotingActivator.CreateInstance<Session>(userId);   // was: new Session(userId)

The url-taking form is named CreateInstanceAt rather than being an overload — with both present, CreateInstance<Cart>("owner") binds to the url overload and silently treats a constructor argument as an endpoint address. That bug showed up in the sample within minutes of the overload existing.

3. Domain creation and unload
AppDomain.CurrentDomain.CreateChildDomain("worker")   // was: AppDomain.CreateDomain("worker")
domain.Unload()                                       // was: AppDomain.Unload(domain)
4. A few members could not keep their signature

AssemblyAppDomainSetup, evidence and — less obviously — AssemblyName cannot cross a process boundary. AssemblyName.GetObjectData throws PlatformNotSupportedException off .NET Framework, which is the sort of thing you only discover by measuring.

ClassicReplacement
AppDomainSetupAppDomainSetup2 — same property names; the platform owns the original type on .NET 8 or .NET 10
domain.Load(name) → Assemblydomain.LoadAssembly(name) → AssemblyName
domain.GetAssemblies() → Assembly[]domain.GetLoadedAssemblies() → AssemblyName[]
AssemblyResolve returning AssemblyAssemblyResolve returning a path or raw bytes
domain.SetupInformationdomain.Setup

Settings with no meaning off .NET Framework — ShadowCopyFilesLoaderOptimization, evidence, permission sets — are deliberately not declared at all. A settable property that silently does nothing is a latent bug; a compile error is a prompt to think about what the code actually needed.

5. Suppress two obsoletion warnings
<NoWarn>$(NoWarn);SYSLIB0010;CS0672</NoWarn>
XML

MarshalByRefObject.InitializeLifetimeService is [Obsolete] on modern .NET (SYSLIB0010) and overriding it also raises CS0672. Both are harmless here: the library calls the override itself and handles the PlatformNotSupportedException the base implementation throws — which is what lets a class that returns its own lease keep working, and one that defers to base keep working too.

That is the whole list.

What You Give Up

An honest accounting matters more than a feature table, so:

  • No wire compatibility with unmodified .NET Framework peers. Both ends must reference these packages. This was the deliberate trade that allowed a clean protocol and a serializer that is not BinaryFormatter.

  • No AOT, no full trimming. Proxies are generated with Reflection.Emit.

  • No HTTP/SOAP channel, no ContextBoundObject, no context attributes. SOAP serialization has no supported equivalent off .NET Framework; ref="http" in a config file throws and names tcp and ipc as the alternatives.

  • Domains cost more to start — roughly 50–100 ms against about 1 ms. Pool and reuse them rather than creating one per unit of work.

  • A domain is not a security boundary. Domains talk over loopback TCP bound to 127.0.0.1 with unguessable capability uris. That is hardening, not isolation — another process running as the same user can still reach one. Do not use domains to sandbox code you do not trust.

  • One thread per in-flight call. IMessageSink.SyncProcessMessage is synchronous by contract, so a caller blocks a thread until the reply arrives. A call graph that bounces between processes consumes a thread per hop, and the thread pool injects new threads at roughly one per 500 ms once saturated — which surfaces as calls that take seconds and then recover. Deployments with deep callback chains should raise ThreadPool.SetMinThreads. Fixing this properly would mean abandoning the IMessageSink shape, and that shape is what makes this a compatibility layer rather than a new framework.

  • Emitted proxy types are never unloaded. The factory uses AssemblyBuilderAccess.Run and caches each contract's proxy for the process lifetime, which is what makes the second call cheap. Bounded by the number of distinct contracts — a deployment constant, not a function of traffic.

  • Lifetime leases are lazy. A lease is created only when something asks for one, so a published object with no lease is never reaped. A long-lived domain that creates a worker per unit of work must call domain.Release(instance); nothing else will.

Installing

Remoting only:

<PackageReference Include="Net4x.Runtime.Remoting" Version="1.0.0" />

Application domains — both packages are required:

<PackageReference Include="Net4x.AppDomain" Version="1.0.0" /><PackageReference Include="Net4x.AppDomain.Host" Version="1.0.0" />

Net4x.AppDomain is the API you compile against. Net4x.AppDomain.Host contains no API at all — it delivers the host program, one process of which is started per domain. It has to be a separate package because NuGet never copies a tools/ folder into your output directory, and a bare executable in lib/ would arrive without the runtimeconfig.json that dotnet exec needs. The host package carries build targets that place the complete host at tools/net8.0/ and tools/net10.0/ in your output folder, which is where the launcher looks.

Reference Net4x.AppDomain alone and it compiles fine, then throws on the first CreateChildDomain with a message listing every path it searched. Fail loudly, and name the fix.

The Host Must Match Your Runtime

Both host builds ship, and the launcher picks the one matching the runtime the parent is on: an exact match first, then the closest older build, then a newer one. That ordering is not cosmetic. A child process running .NET 8 cannot load an assembly built for .NET 10 — and the failure does not say so. The assembly loads at metadata level and then Assembly.GetType simply returns null, so what you get is:

Type 'Acme.Plugin.Entry' was not found in assembly 'Acme.Plugin' inside domain 'plugin'.

A type-not-found message for a type that plainly is in the assembly, with nothing pointing at the real cause. It is worth knowing the shape of this if you ever hand-deploy the host or pin it to one framework: the host under tools/ has to be at least as new as the code you are loading into it. The library now resolves types in the child with throwOnError: true so the underlying load failure is named, and the error mentions the host's runtime version and the tools/<tfm> layout.

How It Is Verified

Claims about a cross-process library are cheap, so here is what actually runs:

dotnet build Net4x.Runtime.Remoting.slnx -c Release
dotnet test  Net4x.Runtime.Remoting.Tests\Net4x.Runtime.Remoting.Tests.csproj   # 144 tests
dotnet test  Net4x.AppDomain.Tests\Net4x.AppDomain.Tests.csproj                 # 49 tests, real processes
pwsh -File   samples\run-cross-process-sample.ps1                               # 17 cross-process checks
pwsh -File   samples\run-appdomain-sample.ps1                                   # 24 domain checks

Every line of that runs twice, once per target framework: the test projects multi-target net8.0 and net10.0, and both sample scripts loop over the two. That is not redundancy. A domain test only exercises the host build for its own framework, so running only the newest one would leave the .NET 8 host completely untested while still shipping it in the package.

The unit tests alone cannot catch a broken cross-process path. Every one of the 49 domain tests spawns real child processes, the cross-process sample starts two separate executables talking over a real socket, and both compare Environment.ProcessId across the boundary — so they fail if anything is ever quietly served in-process. The cross-process sample is also the only thing that puts the contract in a third assembly, which is what catches an over-strict change to type or method resolution.

Latest cross-process run:

PASS  simple call returns a value
  PASS  decimal survives the wire
  PASS  DateTime survives with its Kind
  PASS  private field round-trips
  PASS  call executed in the server process        <- proves it is not an in-process shortcut
  PASS  out parameter comes back
  PASS  ref parameter comes back
  PASS  generic method (string)
  PASS  generic method (int)
  PASS  server exception propagates with type and message
  PASS  subscribe returned an id
  PASS  server called back into the client during Subscribe
  PASS  server pushed a later callback
  PASS  callback carried the pushed payload
  PASS  client-activated object keeps per-client state
  PASS  client-activated constructor argument was honoured
  PASS  each activation is a distinct instance

ALL CHECKS PASSED

Three Bugs Worth Knowing About

Because they are the kind you would hit yourself building anything similar, and each one produced a silently wrong answer rather than an exception.

Array type names on the wire. The assembly qualifier must go after the array / byref / pointer suffix. System.Int32, MyAsm[] parses back as int, not int[] — so every array-typed payload was quietly corrupted until the suffix order was fixed.

Open generic definitions. A generic method's metadata table entry is the open definition. Passing it across the wire delivers an unbound T whose parameter types have no resolvable names, and every generic call fails server-side. It must be closed at the call site.

IsVirtual as the interceptability test. As above: virtual final passes IsVirtual and cannot be overridden. Sealed overrides and implicit interface implementations sailed through the check and then ran locally on the client.

Conclusion
Not every migration should keep its remoting. If your remote surface is a handful of service calls, gRPC or ASP.NET Core minimal APIs will give you a better result and a smaller dependency footprint.

But "rewrite the distribution layer" is not always a proportionate answer to "we want to run on a supported runtime". When the remoting surface is large, deeply entangled with MarshalByRefObject identity semantics, or reliant on lifetime leases and bidirectional callbacks, a compatibility layer lets you move to .NET 8 or .NET 10 first and modernise the architecture afterwards — as a choice rather than as a prerequisite.

And in two places the replacement is simply better than what it replaces. Callbacks no longer require a reachable, addressable client, so a client behind NAT or in a container can receive server pushes over the connection it already opened. And domain isolation is now real fault isolation: a plugin that hard-crashes its process can no longer take your application down with it. 

Windows Hosting Recommendation

HostForLIFEASP.NET receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2012 R2, SQL Server 2014, ASP.NET 7.0.4, ASP.NET MVC 6.0, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Previous PostOlder Post Home

0 comments:

Post a Comment