Skip to content

Feature/v7 performance - #99

Merged
tylerje merged 20 commits into
masterfrom
feature/v7-performance
Aug 19, 2026
Merged

tylerje merged 20 commits into
masterfrom
feature/v7-performance

Conversation

@tylerje

@tylerje tylerje commented Aug 19, 2026

Copy link
Copy Markdown
Member

ServiceWire 7.0.0 — performance release, negotiated wire protocol v2, and a full user guide

A major performance release plus the documentation to go with it. Steady-state calls are 43–70% faster than 6.0.1 on named pipes and 13–47% faster on TCP; TCP connection setup is roughly 14× faster on .NET 8 and 10.

Every mixed-version pairing keeps working. The new v2 wire activates only when both ends are 7.0, so a fleet can be upgraded in any order with no flag day.


📖 Documentation

The wiki is superseded by a versioned user guide that lives in the repo and travels with the code.

docs/user-guide.md is the hub, linked prominently from the README, with 11 topic pages:

Page Covers
Getting started Three-project walkthrough, full code, how to run it
Designing contracts What a method signature may contain, versioning, threading
Transports and endpoints TCP vs named pipes, pipe ACLs, timeouts, client lifetime
Serialization and compression ISerializer, ICompressor, when to compress
Security Zero-knowledge login, credential storage, hardening checklist
Logging and diagnostics Host.Status supervisor pattern, ILog/IStats, MEL adapter
Interception Pre/post/exception hooks, host-side vs client-side
Wire protocol Message types, v1 and v2 frame layouts, negotiation
Performance Full benchmark tables, tuning guidance, how to re-run
Migrating to 7.0 Compatibility matrix, breaking surface, upgrade checklist
Troubleshooting Symptom → cause → fix for the common failures

13 hand-authored SVG diagrams in docs/images/ — call path, frame layouts, ZK handshake sequence, host status lifecycle, benchmark chart. All relative links and heading anchors are verified; all SVGs are XML-valid and render in both GitHub themes.

Behaviour verified against the built library while writing these, correcting long-standing assumptions:

  • Interface properties and indexers do work — each accessor is a round trip
  • Generic methods fail at client construction (T Echo<T>(T) cannot be proxied)
  • Events build a proxy but throw on += — there is no callback channel
  • out decimal / ref Guid throw NotSupportedException at construction, not at call time

The README is restructured around a quick start and the guide link; the full version history is preserved, and the 2024 benchmark run moved into docs/performance.md rather than being dropped.


⚡ Internal optimizations

v1 wire format is unchanged, proven byte-identical by golden tests.

  1. Memoized type-to-config-name resolution in both directions, removing three regex passes per complex parameter per call and repeated Type.GetType lookups in DefaultSerializer.
  2. Fixed the named-pipe client to actually use its BufferedStream, collapsing dozens of per-field pipe syscalls per call into one (and fixing a pipe stream that was never disposed).
  3. Set Socket.NoDelay on client and accepted sockets to eliminate Nagle and delayed-ACK latency; both sides already buffer and flush once per message.
  4. Server dispatch through compiled expression delegates instead of MethodInfo.Invoke (byref methods fall back to reflection); async results through compiled Task.Result getters; client Task.FromResult wrapping through compiled converters. Client-visible exception behaviour is unchanged and covered by parity tests.
  5. Proxy types are created and their constructors compiled once per pooled builder; creating a proxy is now a delegate call.
  6. Larger named-pipe server buffers, connect-path event wait instead of a spin loop, CompressionLevel.Fastest in the default compressor, and reused ZK cipher instances (identical ciphertext).

🔌 Wire protocol v2

Default on both transports, negotiated per connection, never sent to a 6.x peer.

  1. Servers advertise capabilities through a new additive ServiceSyncInfo.CapabilityFlags member.
  2. Framed MethodInvocation2/Response2 messages with length prefixes and correlation ids: a request payload that fails to decode is answered with a correlated error response instead of desynchronizing the stream and killing the connection.
  3. DateTime values travel as ToBinary() — Kind-preserving, no string parsing.
  4. Concurrent in-flight calls on a shared TCP client proxy. Callers no longer serialize on a whole-round-trip lock; whichever caller holds the read seat pairs responses by correlation id while the server executes each connection's requests strictly in order. An uncontended caller reads inline with no thread handoff.
  5. Named-pipe channels speak v2 with the exchange serialized per channel — synchronous pipe handles cannot overlap a read with a write. Pipes still gain decode-error resilience and binary DateTime transfer on top of the Tier 1 buffering wins.
  6. Three opt-outs force the classic v1 wire: Host.EnableWireV2 = false (before AddService), and TcpEndPoint.UseWireV2 = false / NpEndPoint.UseWireV2 = false per client.
  7. In-place server downgrades with a stale cached capability produce a descriptive error and evict the cache so the next channel renegotiates.

🐛 Bug fixes

  1. A string[] above the compression threshold was written with a type code no receiver could decode; it now uses CompressedUnknown, which every release since 1.5.0 can read.
  2. Scalar Type parameters crashed the default serializer; they now use the wire format's Type code.
  3. Thrown exceptions could not be serialized by System.Text.Json (TargetSite), which killed the connection whenever a service method threw with the default serializer. A converter now preserves the exception type, message, HResult, inner chain, and server stack trace.

📦 Packaging and opt-ins

The package now multi-targets:

  • netstandard2.0 keeps the exact 6.x dependency graph, so existing .NET Framework and .NET Standard consumers see no new binding redirects.
  • net8.0 has no package dependencies, plus span-based fast paths that produce identical wire bytes.

Assembly version is pinned at 7.0.0.0 across both TFMs so mixed loads bind cleanly.

Opt-in additions, all defaulting to prior behaviour:

Addition Where Default
ReceiveTimeoutMs, SendTimeoutMs TcpEndPoint, TcpHost 0 — infinite, as before
PersistentFileWriter LoggerBase false
EnableWireV2 Host true
UseWireV2 TcpEndPoint, NpEndPoint true

⚠️ Breaking surface

This is a major version for exactly one reason: ServiceSyncInfo gained a member.

[DataMember(Order = 5)]
public int CapabilityFlags { get; set; }

Every tolerant serializer handles this — System.Text.Json ignores unknown members, protobuf-net skips unknown field numbers, Newtonsoft ignores extras by default. A strict custom ISerializer on a 6.x client, configured to reject unmapped members or hand-coding the field list, may need updating. It is declared as int rather than an enum so every serializer sees a plain scalar.

Compatibility matrix:

Host on 6.x Host on 7.0
Client on 6.x v1 wire — unchanged v1 wire — host advertises v2, old client ignores the flag
Client on 7.0 v1 wire — no capability advertised v2 wire

📊 Benchmarks

Identical suite (BenchmarkDotNet 0.15.8), same machine, same day: Windows 11, AMD Ryzen Threadripper PRO 5975WX, .NET SDK 10.0.204. Means are microseconds per call; lower is better.

Steady state — persistent host and client

Benchmark .NET 10 6.0.1 .NET 10 7.0.0 Faster by .NET 8 6.0.1 .NET 8 7.0.0 Faster by .NET 4.8 6.0.1 .NET 4.8 7.0.0 Faster by
NpSim 27.9 14.9 47% 27.9 15.3 45% 32.7 18.0 45%
NpSimJson 27.7 14.4 48% 27.9 15.8 43% 32.7 18.2 44%
NpRg 66.5 19.7 70% 72.7 22.9 68% 129.5 46.9 64%
NpRgJson 68.1 26.9 60% 80.0 30.1 62% 137.8 54.2 61%
NpCxOut 65.1 20.5 69% 71.0 22.2 69% 103.3 36.4 65%
NpCxOutJson 67.0 22.3 67% 74.8 24.7 67% 107.4 43.8 59%
TcpSim 47.9 40.9 14% 48.1 41.7 13% 56.8 48.9 14%
TcpSimJson 47.4 40.8 14% 48.1 41.8 13% 57.5 49.5 14%
TcpRg 91.9 51.7 44% 97.5 54.5 44% 161.0 84.8 47%
TcpRgJson 91.9 51.1 44% 97.6 55.5 43% 160.1 84.5 47%
TcpCxOut 87.7 51.7 41% 94.1 54.3 42% 130.1 76.3 41%
TcpCxOutJson 86.6 51.7 40% 93.3 54.6 42% 130.5 76.0 42%

Connection setup — host and client built and torn down per operation

Benchmark .NET 10 6.0.1 .NET 10 7.0.0 .NET 8 6.0.1 .NET 8 7.0.0 .NET 4.8 6.0.1 .NET 4.8 7.0.0
TcpConn 15,385 1,114 15,496 1,111 976 1,083
NpConn 331 507 328 451 322 530

The 6.0.1 TcpConn figure is real — the old connect path busy-waited on SpinWait.SpinUntil, which degrades badly on modern runtimes. Named-pipe connection setup is 0.1–0.2 ms slower in 7.0.0 (split read/write buffered streams, larger pipe buffers); the per-call savings repay that within a handful of calls on any connection that is actually used.

Shapes the suite does not cover, measured separately: a client sending an int[1000] over a named pipe is roughly 10× faster (the 6.x pipe client issued one write syscall per array element), concurrent callers sharing one TCP proxy gain up to 2× from v2 pipelining, and DateTime-heavy payloads avoid string formatting and parsing entirely.


✅ Testing

  • Unit tests: 65 passed, 6 skipped, 0 failed on both net10.0 and net48
  • Golden wire-format tests assert the v1 bytes are byte-identical to 6.x
  • New interop matrix runs the published ServiceWire 6.0.1 package as a separate process against 7.0 in both directions, across TCP and named pipes, with and without compression — wired into CI
  • New pipelining tests cover concurrent callers on a shared proxy, including that the server still executes one connection's requests in order
  • New compiled-dispatch tests cover exception parity between the compiled and reflection paths

🔍 Review notes and follow-ups

Surfaced while documenting; none are regressions introduced by this branch except the first, and all are judged non-blocking.

  1. Unbounded frame length (new in this branch). Host.ProcessInvocation2 and StreamingChannel.ReadOneResponseFrame both call ReadBytes(frameLength - header) with no upper bound, so a corrupt or hostile length allocates up to ~2 GB before anything validates it. Low severity under the trusted-peer model, but a MaxFrameBytes guard is cheap and worth a follow-up.
  2. ZkProtocol.CryptRand is not cryptographically random (pre-existing since 2.0.0). It uses new Random(DateTime.Now.Millisecond) — roughly 1000 possible seeds — for salts, ephemerals and session-key material. docs/security.md now states this limitation plainly and recommends tunnelling anything crossing an untrusted network rather than implying audited-SRP strength.
  3. IsDebugEnabled() returns true for any custom ILog (pre-existing). Injecting a Serilog or MEL adapter makes ServiceWire build every debug string unconditionally, including base64 payloads on ZK connections — a hot-path cost and a place secrets can reach a log. Documented as a caveat in docs/observability.md.
  4. Stats and Logger resolve their default directory differently (pre-existing). Stats uses Assembly.GetEntryAssembly().Location, which can be null and is empty under single-file publish, so new Stats() can throw where new Logger() does not.

One decision worth a look

The README is packed into the NuGet package, so the hero image uses an absolute raw.githubusercontent.com/.../master/... URL. That renders on GitHub today but 404s until this branch merges to master, and nuget.org may not render SVG at all. A PNG would be safer for the NuGet surface if we want it guaranteed.

tylerje and others added 15 commits August 18, 2026 13:57
Locks the v1 parameter-block wire format byte-for-byte with an independent
reference encoder before any optimization work. Compressed frames are
asserted structurally (type byte + frame layout + post-decompression
payload) since gzip output varies by runtime.

Also documents via test exclusion that scalar Type parameters are broken
today (runtime type RuntimeType misses the exact-type map and falls to the
serializer, which cannot serialize RuntimeType); fixed in a later stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All changes are byte-identical on the wire (proven by golden tests) or fix
frames no existing receiver could decode:

- Memoize Type<->config-name resolution in NetExtensions (ConcurrentDictionary
  both directions; only successful Type lookups cached so late assembly loads
  still resolve). Removes 3 regex passes per complex parameter per call and
  the double Type.GetType in DefaultSerializer.Deserialize.
- Fix NpChannel: BinaryReader/Writer now wrap the BufferedStream instead of
  the raw pipe stream, collapsing per-field pipe syscalls to one per message
  and fixing the never-disposed pipe stream leak.
- Socket.NoDelay = true on client and accepted server sockets; both sides
  already buffer and flush once per message.
- NpListener pipe buffers 512 -> 8192.
- Replace TCP connect SpinWait busy-loop with ManualResetEventSlim wait.
- DefaultCompressor: CompressionLevel.Fastest, pre-sized buffers.
- ZkCrypto: create the symmetric algorithm once (same mode/padding/key/IV,
  identical ciphertext), per-direction locks, IDisposable.
- Bug fix: string[] over compression threshold now writes CompressedUnknown
  (0x23, readable by every release since v1.5.0) instead of Unknown with a
  compressed payload no receiver could decode; null elements no longer NRE
  the size probe.
- Bug fix: scalar Type parameters (runtime type RuntimeType) now map to the
  0x12 Type code and send the value's config name; previously they fell to
  the serializer path and crashed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion fix

- New MethodInvokerCompiler compiles service methods into
  Func<object, object[], object> via expression trees at AddService time;
  Host and InterceptChannel dispatch through the compiled delegate and fall
  back to MethodInfo.Invoke for byref methods. Compiled delegates throw raw
  exceptions; the existing conditional TargetInvocationException unwrap makes
  client-visible behavior identical (covered by new parity tests).
- Async results: cached compiled Func<Task, object> Result getters replace
  per-call PropertyInfo.GetValue.
- Client: cached compiled Task.FromResult<T> converters replace per-call
  reflection Invoke for async-signature methods.
- ProxyFactory: proxy Type is created and its constructor compiled once per
  pooled builder; CreateProxy is now a delegate call instead of
  CreateTypeInfo + GetConstructor + ConstructorInfo.Invoke per proxy.
- Bug fix (pre-existing since 5.5.0): System.Text.Json cannot serialize
  thrown exceptions (TargetSite), so any service exception killed the
  connection and clients saw EndOfStreamException. DefaultSerializer now
  registers an exception converter that preserves type, message, HResult,
  inner chain, and server stack trace, writing a JSON shape old receivers
  still parse without error. Also caches JsonSerializerOptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er writer

- ServiceWire now ships lib/netstandard2.0 (unchanged 6.x dependency graph,
  so existing net48/netstandard consumers see no new binding redirects) and
  lib/net8.0 (no package dependencies; in-box APIs). AssemblyVersion pinned
  to 7.0.0.0 across TFMs.
- net8.0-only, wire-byte-identical fast paths guarded by NET8_0_OR_GREATER:
  bulk span read/write for primitive arrays (little-endian bulk copy equals
  the per-element BinaryWriter bytes; BE platforms keep loops), stackalloc
  Guid writes, Aes.Create() for the ZK cipher (identical ciphertext), and
  RuntimeHelpers.GetUninitializedObject in the exception converter.
- SHA256CryptoServiceProvider -> SHA256.Create() (obsolete on net8).
- Opt-in TCP socket timeouts: TcpEndPoint.Receive/SendTimeoutMs for clients
  and TcpHost.Receive/SendTimeoutMs for accepted sockets; default 0 keeps
  infinite (pre-7.0) behavior.
- Opt-in LoggerBase.PersistentFileWriter keeps the log file open between
  flushes; default false preserves open/append/close per flush.

Both compiled assets pass the full suite including byte-for-byte goldens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nary DateTime)

- ServiceSyncInfo gains CapabilityFlags (DataMember Order 5, plain int);
  servers advertise ProtocolCapabilities.WireV2 unless Host.EnableWireV2 is
  set false before AddService. Pre-7.0 clients ignore the extra member;
  pre-7.0 servers leave it 0 so 7.0 clients stay on the unchanged v1 path.
  Both mixed-version pairings therefore keep working over v1.
- New framed messages: MethodInvocation2 (6) and Response2 (7), layout
  msgType | frameLength | correlationId | serviceKey+methodIdent (request) |
  status byte (response) | flags | payload. Correlation ids are per-channel
  Interlocked counters.
- v2 parameter encodings: DateTime2/ArrayDateTime2 carry DateTime.ToBinary()
  (Kind-preserving, no string parse); all other codes byte-identical to v1.
  The v1 reader rejects v2 codes; the v1 encoder is textually untouched and
  its goldens still pass.
- Host.ProcessInvocation refactored into shared stages (Materialize/
  Execute/WriteReturn) reused verbatim by the new ProcessInvocation2, which
  buffers the framed payload up front: a payload that fails to decode is
  answered with a correlated error response and the connection survives
  (raw-socket tests prove frame-skip recovery and status-2 correlation).
- Client detects the capability after SyncInterface; a stale process-wide
  cache entry claiming v2 against a downgraded server is evicted with a
  descriptive error on the first failed v2 exchange.
- Full suite now exercises v2 end to end (TCP, NP, ZK, compression,
  async, byref) plus explicit v1 fallback via EnableWireV2 = false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the whole-round-trip lock for v2 channels. Callers serialize only on
a write lock covering the frame write; a lazily started reader thread
demultiplexes Response2 frames by correlation id into a pending-request
table of TaskCompletionSources, so each caller blocks solely on its own
response. v1 channels keep the original lock and thread profile exactly.

Transport work this required:
- Client and server now buffer reads and writes independently (two
  BufferedStreams over one NetworkStream/PipeStream). A single shared
  BufferedStream cannot serve a concurrent reader and writer, and with
  pipelining the server read of one request prefetches the next into the
  read buffer, making the response write throw on non-seekable streams.
- NpChannel opens the pipe with PipeOptions.Asynchronous: a synchronous
  pipe handle serializes concurrent ReadFile/WriteFile, which deadlocks
  the reader thread against writers.
- Explicit flushes added wherever the old shared buffer flushed implicitly
  on the read that followed a write: client SyncInterface and ZK handshake,
  server ZkSession responses.
- Dispose: best-effort terminate+flush under the write lock, defensive
  disposal of both stream wrappers (second one flushes into a closed
  transport), ZkCrypto disposal, and faulting of all pending calls. Reader
  loop faults every in-flight call on any stream error.
- Per-channel method/return-type caches are now ConcurrentDictionary since
  v2 calls no longer serialize.

New PipeliningTests prove correct response pairing across 24 concurrent
callers on one shared proxy, serial per-connection server execution with no
lost updates, and pending-call faulting on dispose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mentation

- New src/Tests/Interop: a shared contract assembly (identical type config
  names on both sides), an Old601Peer console app pinned to the published
  ServiceWire 6.0.1 NuGet package, and an InteropTests xunit driver that
  spawns the old peer as a separate process. Matrix covered: {6.0.1 server
  with 7.0 client, 7.0 server with 6.0.1 client} x {TCP, named pipes} x
  {plain, compression}, plus the in-place downgrade scenario proving a
  stale cached WireV2 capability yields a descriptive error, cache
  eviction, and successful renegotiation over v1.
- The battery exercises the one-way CompressedUnknown path (7.0 sender to
  6.0.1 receiver) and documents why a 6.0.1 peer must never send a large
  compressed string[] (the pre-7.0 sender bug) and why the throw test only
  runs against 7.0 servers (6.0.1 servers cannot serialize thrown
  exceptions with the default serializer).
- CI workflow installs the 8.0.x runtime for the old peer alongside 10.0.x;
  interop projects are in the solution so dotnet test runs them on both OS
  jobs.
- README history entry and NuGet release notes for 7.0.0, including the
  strict-custom-serializer caveat and mixed-fleet guidance
  (Host.EnableWireV2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… results

Performance measurement against the published 6.0.1 package drove three
design corrections:

- Replaced the dedicated reader thread with a reader-seat (leader/follower)
  demux: the caller holding the seat drains response frames, completing
  other callers by correlation id, until its own arrives. An uncontended
  caller reads inline with zero thread handoff (the dedicated thread cost a
  cross-thread wakeup on every sequential call); followers wake exactly on
  seat release because the server answers in request order.
- Named-pipe channels stay on the v1 wire (AllowWireV2 false): sync pipe
  handles cannot overlap reads and writes, so v2 buys no pipelining there,
  and an overlapped handle costs more per op than pipelining recovers
  locally. Pipes get all Tier 1 wins without the v2 frame cost.
- Added client-side TcpEndPoint.UseWireV2 (default true) alongside the
  server-side Host.EnableWireV2 opt-out, and eliminated payload ToArray
  copies on the v2 request/response paths (GetBuffer + length writes).

Measured loopback results (5k-call batches, net8.0) vs 6.0.1: NP seq small
calls +50% (39.7k -> 59.5k calls/s), NP int[100] echo +920% (5.8k -> 58.8k),
NP DateTime +53%, NP 8-thread +38%; TCP 8-thread improved (high variance,
up to 2x), TCP DateTime level, TCP seq small calls about -20% from v2
framing - documented with both opt-outs in the README.

PipeliningTests now exercise true pipelining over TCP and correct pairing
over serialized named pipes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss fixes

- Named-pipe channels now default to the v2 wire like TCP. Their synchronous
  handles cannot overlap a read with a write, so v2 exchanges on a pipe
  serialize per channel (SupportsConcurrentStreamIO false) instead of
  pipelining; pipes gain v2 decode-error resilience and binary DateTime on
  top of the Tier 1 buffering wins. New NpEndPoint.UseWireV2 (default true)
  joins TcpEndPoint.UseWireV2 and Host.EnableWireV2 as v1 opt-outs.
- Benchmark harness repairs (pre-existing): TcpBenchmarks and
  NamedPipesBenchmarks created hosts in their constructors, which also run
  in BenchmarkDotNet's orchestrating process during validation - the held
  ports made every TCP benchmark fail with address-in-use, and a stray pipe
  host could serve the measured client. Hosts now start in GlobalSetup
  (child process only), ConnectionBenchmarks got a distinct port and pipe
  name, and the project forces 64-bit so the net48 child no longer dies
  with BadImageFormatException.
- README updated for the v2-default policy, the three opt-outs, and
  measured results.

All suites green: 65 unit tests x net10.0/net48, 7 interop tests vs 6.0.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the out-of-support net6.0 benchmark job with net10.0 and upgrades
BenchmarkDotNet 0.14.0 -> 0.15.8 for the Net10_0 runtime moniker. .NET 8.0
remains the ratio baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TcpBenchmarks now binds OS-assigned free ports per child process instead of
fixed ports that fail with address-in-use when TIME_WAIT connections from
the previous child linger. ConnectionBenchmarks probes a fresh free port
per operation and bounds its jobs to explicit invocation counts (each
operation opens a real TCP connection; unbounded runs exhausted the 16K
Windows ephemeral port pool within seconds because closed connections
linger in TIME_WAIT for minutes). All 42 benchmarks now measure on
.NET 10, .NET 8, and .NET Framework 4.8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in README

- Service method invokers now compile on each method's first invocation
  instead of eagerly at AddService, which cost about half a millisecond per
  service registration and penalized short-lived hosts (visible in the
  per-operation connection benchmarks). Byref methods cache null and keep
  the reflection path. Same change in InterceptChannel.
- README gains a measured 6.0.1 vs 7.0.0 comparison: identical modernized
  BenchmarkDotNet 0.15.8 suite, same machine and day, across .NET 10,
  .NET 8, and .NET Framework 4.8. Steady-state named-pipe calls are 43-70%
  faster, TCP calls 13-47% faster, and TCP connection setup ~14x faster on
  modern runtimes (the 6.x connect spin-wait degraded to ~15 ms there).
  Honest caveats included: named-pipe connection setup is 0.1-0.2 ms
  slower, and the v2 frame trades a few microseconds and ~1 KB per call
  for pipelining and decode-error resilience, with v1 opt-outs available.

The 6.0.1 baseline was produced from an uncommitted git worktree of master
carrying the same benchmark harness changes. Note for future runs:
BenchmarkDotNet resolves the benchmark csproj from the current working
directory, so runs must execute with CWD inside the intended checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/ServiceWire/ZeroKnowledge/ZkCrypto.cs Fixed
@sonarqubecloud

Copy link
Copy Markdown

@tylerje
tylerje merged commit 20852e7 into master Aug 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants