Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7ad2e3d
v7 stage 1: golden v1 wire-format tests and 7.0.0-preview.1 version bump
tylerje Aug 18, 2026
408d091
v7 stage 2: I/O and caching optimizations, wire-safe bug fixes
tylerje Aug 18, 2026
bdc4b6d
v7 stage 3: compiled-delegate dispatch and thrown-exception serializa…
tylerje Aug 18, 2026
67fe993
v7 stage 4: multi-target netstandard2.0;net8.0, opt-in timeouts, logg…
tylerje Aug 18, 2026
afd9972
v7 stage 5: negotiated wire protocol v2 (framing, correlation ids, bi…
tylerje Aug 18, 2026
5d43186
v7 stage 6: client demux - concurrent in-flight calls per v2 channel
tylerje Aug 18, 2026
13f25b2
v7 stage 7: cross-version interop matrix, CI wiring, and release docu…
tylerje Aug 18, 2026
ce5fa96
v7 stage 8: reader-seat demux, transport-specific v2 policy, measured…
tylerje Aug 18, 2026
e5f8e69
v7 stage 9: v2 wire is the default on all transports; benchmark harne…
tylerje Aug 18, 2026
30555f8
Ignore BenchmarkDotNet.Artifacts output directory
tylerje Aug 18, 2026
ca0b9b0
Benchmarks target .NET 10, .NET 8, and .NET Framework 4.8
tylerje Aug 18, 2026
0d704bb
Fix TCP benchmark port exhaustion and rebinding failures
tylerje Aug 18, 2026
ce9df85
Lazy server dispatch compilation; 6.0.1 vs 7.0.0 benchmark comparison…
tylerje Aug 18, 2026
3c7072f
v7.0.0 final
tylerje Aug 19, 2026
8a42a07
Upgrade guidance
tylerje Aug 19, 2026
2b85008
copyright and test update
tylerje Aug 19, 2026
c0654d9
SonarCloud fixes
tylerje Aug 19, 2026
9dbfcb2
More SonarCloud fixes
tylerje Aug 19, 2026
697526c
More CI fixes
tylerje Aug 19, 2026
c0f9992
More CI fixes
tylerje Aug 19, 2026
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
88 changes: 80 additions & 8 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,99 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# 8.0.x runs the ServiceWire 6.0.1 interop peer; 10.0.x runs the build and tests
dotnet-version: |
8.0.x
10.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
id: build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal

# One step per test project and target framework. A single solution-wide
# "dotnet test" runs the multi-targeted unit tests through the VSTest
# cross-targeting dispatcher, which reports a failing inner build as
# "MSB4181: the MSBuild task returned false but did not log an error" and
# hides which framework failed and why. Running each framework explicitly
# surfaces the real error. Blame collection names the test that was running
# if the host dies, and the hang timeout turns a deadlock into a report
# instead of a job timeout.
# Each step runs whenever the build succeeded, so one failing framework does
# not mask the results of the others.
- name: Test unit (net10.0)
if: steps.build.outcome == 'success'
run: >
dotnet test Tests/Unit/ServiceWireTests/ServiceWireTests.csproj
--no-build -f net10.0 --verbosity normal
--blame --blame-hang --blame-hang-timeout 5m
--logger "trx;LogFileName=unit-net10.trx" --results-directory TestResults

- name: Test interop matrix (net10.0)
if: steps.build.outcome == 'success'
run: >
dotnet test Tests/Interop/InteropTests/InteropTests.csproj
--no-build --verbosity normal
--blame --blame-hang --blame-hang-timeout 5m
--logger "trx;LogFileName=interop-net10.trx" --results-directory TestResults

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results-linux
path: src/TestResults
if-no-files-found: ignore

build_windows:

runs-on: windows-latest

steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# 8.0.x runs the ServiceWire 6.0.1 interop peer; 10.0.x runs the build and tests
dotnet-version: |
8.0.x
10.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
id: build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal

# See the comment in build_linux. Windows also runs the net48 target of the
# unit tests, which is the pairing the cross-targeting dispatcher obscures.
- name: Test unit (net10.0)
if: steps.build.outcome == 'success'
run: >
dotnet test Tests/Unit/ServiceWireTests/ServiceWireTests.csproj
--no-build -f net10.0 --verbosity normal
--blame --blame-hang --blame-hang-timeout 5m
--logger "trx;LogFileName=unit-net10.trx" --results-directory TestResults

- name: Test unit (net48)
if: steps.build.outcome == 'success'
run: >
dotnet test Tests/Unit/ServiceWireTests/ServiceWireTests.csproj
--no-build -f net48 --verbosity normal
--blame --blame-hang --blame-hang-timeout 5m
--logger "trx;LogFileName=unit-net48.trx" --results-directory TestResults

- name: Test interop matrix (net10.0)
if: steps.build.outcome == 'success'
run: >
dotnet test Tests/Interop/InteropTests/InteropTests.csproj
--no-build --verbosity normal
--blame --blame-hang --blame-hang-timeout 5m
--logger "trx;LogFileName=interop-net10.trx" --results-directory TestResults

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results-windows
path: src/TestResults
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,5 @@ src/pack/lib/net461
.vs/
.vscode/
/src/Benchmarks/ServiceWire.Benchmarks/BenchmarkDotNet.Artifacts

BenchmarkDotNet.Artifacts/
195 changes: 105 additions & 90 deletions README.md

Large diffs are not rendered by default.

257 changes: 257 additions & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
# Designing contracts

A ServiceWire contract is an ordinary C# interface. No attributes, no base interface, no partial classes. What it *can* contain is shaped by what the wire can carry.

![What crosses the wire on one call](images/contracts.svg)

---

## The rules in one place

| | |
| --- | --- |
| Must be an `interface` | `AddService` throws on a class |
| Methods may return `void`, any supported type, or `Task`/`Task<T>` | see [Async and Task returns](#async-and-task-returns) |
| `out` and `ref` parameters are supported | except non-primitive value types — see below |
| Overloads are fine | methods are matched by name **and** parameter types |
| Inherited interfaces are included | the host walks base interfaces too |
| Properties and indexers work | each accessor becomes a round trip — see below |
| Generic methods and events do **not** work | see below |
| Exceptions propagate | thrown on the host, rethrown on the caller |

---

## Types that ride for free

These are written straight to the wire with a one-byte type code. No serializer, no reflection, no allocation beyond the value itself:

`bool` · `byte` · `sbyte` · `char` · `short` · `ushort` · `int` · `uint` · `long` · `ulong` · `float` · `double` · `decimal` · `string` · `Guid` · `DateTime` · `Type`

…and single-dimensional arrays of every one of them.

Everything else — your classes and structs, `List<T>`, dictionaries, nested object graphs — is handed to the [serializer](serialization.md). That works fine; it is simply the slower path, which matters only in hot loops.

```csharp
public interface IReadings
{
// fast path end to end
double[] Recent(int count);

// the DateTime[] is fast; the Reading[] goes through the serializer
Reading[] Between(DateTime from, DateTime to);
}
```

### Enums

Enums are converted to their underlying integral type, so they travel on the fast path — including as `out` and `ref` parameters.

```csharp
public enum Result : ushort { Failed = 0, Ok = 1 }

public interface IJobs
{
Result Run(int jobId, out Result detail, ref Result mode); // works
}
```

---

## Properties, indexers, generics and events

**Properties and indexers work.** The compiler turns them into `get_`/`set_` methods, and those are proxied like any other method:

```csharp
public interface IConfig
{
int RetryLimit { get; set; }
string this[string key] { get; }
}

client.Proxy.RetryLimit = 5; // one round trip
int limit = client.Proxy.RetryLimit; // another round trip
string value = client.Proxy["region"]; // another
```

They work, but they hide their cost. `if (proxy.RetryLimit > 0 && proxy.RetryLimit < 10)` is two network calls that read as one local comparison. Methods make the cost visible; prefer them on anything a caller might touch in a loop.

**Generic methods do not work.** The proxy cannot be built for them, and the failure happens when you construct the client, not when you call:

```csharp
T Echo<T>(T value); // constructing the client throws
```

Use concrete overloads, or a single method taking a common base type.

**Events do not work.** The proxy is created successfully, but subscribing throws — a delegate cannot be sent across the wire:

```csharp
event EventHandler Fired; // += throws NotSupportedException at run time
```

ServiceWire is request/response only. There is no server-initiated callback channel. If a client needs to know about something, poll for it or give the host a way to reach the client — a second ServiceWire host in the client process, for example.

---

## `out` and `ref` parameters

They work, and they come back filled in. The one restriction is **non-primitive value types by reference**: `decimal`, `Guid`, `DateTime` and your own structs cannot be `out` or `ref` parameters.

```csharp
long TestLong(out long id1, out long id2); // fine
bool TryGet(int key, out string value); // fine
void Adjust(ref int count); // fine

void Bad(out decimal amount); // not supported
void AlsoBad(ref Guid id); // not supported
```

The failure is loud and early. Constructing the client throws before any call is made:

```text
System.NotSupportedException: Non-primitive native types (e.g. Decimal and Guid)
ByRef are not supported.
at ServiceWire.ProxyFactory.GenerateILCodeForMethod(...)
```

Return a value or a small result type instead:

```csharp
public struct Adjustment
{
public decimal Amount { get; set; }
public Guid Id { get; set; }
}

Adjustment Adjust(int count); // fine
```

Input-only parameters are not echoed back, so a large `in` argument costs one trip, not two.

Working example: [`src/Tests/ServiceWireTestCommon/TestContracts.cs`](../src/Tests/ServiceWireTestCommon/TestContracts.cs).

---

## Your own types

Any type your [serializer](serialization.md) can handle is legal. With the default serializer (System.Text.Json) that means public, settable properties and a parameterless constructor:

```csharp
public class Order
{
public Guid Id { get; set; }
public string Customer { get; set; }
public List<OrderLine> Lines { get; set; }
}
```

Things that will bite you with the default serializer:

- **Read-only properties** serialize but never come back — give them setters.
- **Cyclic references** throw. Break the cycle, or inject a serializer configured to ignore loops (see [`NewtonsoftSerializer`](../src/Tests/Unit/ServiceWireTests/NewtonsoftSerializer.cs)).
- **Interface- or `object`-typed members** lose their concrete type. Use a concrete type in the contract.

No `[Serializable]` attribute is required. That requirement disappeared in 5.5.0 when `BinaryFormatter` was dropped.

---

## Async and Task returns

`Task` and `Task<T>` return types compile and run, and this is the common shape today:

```csharp
public interface IOrders
{
Task<Order> GetAsync(Guid id);
}

var order = await client.Proxy.GetAsync(id);
```

But understand what you are getting. **RPC here is synchronous.** The `Task` wrapper is stripped on the wire; your host method runs to completion on a host thread, and the client wraps the result back into a completed `Task` before handing it to you. So:

- `await` does not release the calling thread while the call is in flight — the proxy call itself blocks, then returns an already-completed task.
- `CancellationToken` parameters have no effect across the wire.
- Exceptions still surface correctly through `await` ([test](../src/Tests/Unit/ServiceWireTests/AsyncTests.cs)).

Use `Task` returns if they fit your call sites. Do not expect them to buy you asynchrony. If you want the calling thread back, wrap the call yourself:

```csharp
var order = await Task.Run(() => client.Proxy.Get(id));
```

---

## Exceptions

When your service method throws, ServiceWire carries the exception back and rethrows it on the caller's thread. The connection survives; the next call proceeds normally.

```csharp
// host
public Statistics Summarize(IList<double> samples)
{
if (samples.Count == 0) throw new ArgumentException("No samples supplied.");
...
}

// client
try
{
client.Proxy.Summarize(empty);
}
catch (ArgumentException ex)
{
// ex.Message, ex.HResult, ex.InnerException and the server stack trace are intact
}
```

The default serializer preserves the exception type, message, `HResult`, the inner-exception chain and the server-side stack trace. A **custom** `ISerializer` must be able to serialize exception objects too — if it cannot, the failure happens while writing the response and takes the connection with it. This is the single most common cause of "the connection dies whenever my method throws"; see [Troubleshooting](troubleshooting.md).

---

## Versioning a contract

Type identity on the wire strips assembly version, culture and public key token, and core framework types are unqualified. Two consequences:

- **A .NET Framework client can call a .NET host** and vice versa; `System.String` matches across both.
- **The contract assembly's version may differ** between the two sides. Its *name* and the namespace-qualified type names may not.

Methods are resolved by name and parameter types against the host's map, so:

| Change | Old clients | Old hosts |
| --- | --- | --- |
| Add a method | keep working | — |
| Add an interface to the host | keep working | — |
| Rename a method or change a parameter type | break | break |
| Remove a method | break if they call it | — |
| Reorder methods | keep working | keep working |

A client calling a method the host does not have gets `Cannot match method '<name>' to its server side equivalent`.

---

## Threading

Your implementation is a **singleton shared by every client**. The host runs one thread per connection and executes that connection's requests in order, but different connections run concurrently. Guard mutable state:

```csharp
public class CounterService : ICounter
{
private int _count;
public int Increment() => Interlocked.Increment(ref _count);
}
```

If your service implements `IDisposable`, the host disposes it when the host is closed.

---

## Next steps

- [Serialization and compression](serialization.md) — moving your own types efficiently
- [Transports and endpoints](transports.md) — where the contract is hosted
- [Interception](interception.md) — adding behaviour around methods without editing them

---

[← Back to the user guide](user-guide.md)
Loading
Loading