Skip to content

v0.43.0

Choose a tag to compare

@github-actions github-actions released this 21 Sep 11:41
· 17 commits to develop since this release
f277514

Added

  • The transport's read path is now tested outside the Editor (Cuvara/IndieRPGMMOAdventure#50):
    Tests~/Headless/Cuvara.Netcode.Tests.Headless.csproj, a net10.0 project that compiles
    package sources directly and runs 30 tests in under a second, plus a Headless tests (dotnet)
    CI job that runs it on every PR.

    TcpTransport and WireConnection await through UniTask, which needs UnityEngine, so
    the whole read/write path was reachable only from inside the Editor or a built player. The
    rest of the package is not like this — prediction, interpolation, world state and snapshot
    resolution are plain C# — and the transport was the one part with nothing.

    What that cost is on the record. A client was measured decoding 13.7 snapshots/s from a
    server sending 15.0/s
    , and establishing what it meant took a socket-level probe written in
    the server repo, a from-scratch reimplementation of Unity's SynchronizationContext
    semantics in a standalone harness, three rebuild-and-run cycles against a live stack, and a
    comparison of the Windows performance counter against the Linux monotonic clock. The answer
    was the machine's clock. Every step of that was reasoning about a property a fifty-line test
    measures directly: given a socket delivering N frames per second, how many reach the
    consumer?

    It also meant a real ceiling in that path went unnoticed until it was hunted for other
    reasons. Every await there goes through Task.AsUniTask(), whose continuation is drained
    once per player-loop frame, so an await costs a whole frame even when the bytes are already
    in the socket buffer — and the read loop caps at playerLoopHz / awaitsPerFrame. Two awaits
    per frame is half the frame rate: 10.0/s at 20 fps, 5.0/s at 10 fps, with the socket
    backlog growing without bound below the knee. Harmless on a desktop, squarely in the way on
    Android. The buffered read that removes it shipped verified by nothing automated, for
    exactly this reason.

  • FrameBuffer (Runtime/Transport/FrameBuffer.cs): the receive-side framing state
    machine — the growable buffer, the length-prefix parsing, the compaction and the growth rule
    — split out of TcpTransport as plain C# with no UniTask and no socket.

    This is not a tidy-up. The property that governs throughput is not in the awaiting, it is in
    how many awaits a frame costs, and that is decided entirely by whether a frame can be
    produced from bytes already in hand. Splitting the decision out of the awaiting is what puts
    it under dotnet test. TcpTransport.ReadFrameAsync is now TryTakeFrame / ReserveForRead
    / Commit around the same single socket read; behaviour is unchanged.

    ReserveForRead also documents and tests a property the old inline code relied on silently:
    it never hands out a zero-length region. A zero-length read comes back from the socket as a
    clean EOF, so the failure mode there is a hang or a spurious disconnect rather than an error.

  • TransportReadPumpTests: the measurement itself. It drives the real FrameBuffer
    through a model of the player loop's drain semantics — at most one socket read per tick,
    unlimited synchronous frame extraction per tick
    — against a virtual server writing at 15/s,
    and asserts the shipping reader holds the server's full rate down to 5 fps, with a steady
    socket backlog and frames in order.

    The fixture keeps the defect permanently, as a control. ExactReadStrategy is the
    two-exact-reads-per-frame shape, and it reproduces the live numbers with no socket and no
    Unity: 15.00/s at 60 and 30 fps, 10.00 at 20, 5.00 at 10, 2.50 at 5, and a backlog that grows
    without bound at 20 fps. Without it a green run here would be indistinguishable from a
    measurement that cannot fail.

    Proven by mutation rather than by assertion: reintroducing the constraint in FrameBuffer
    itself (hand out only the bytes needed to finish the current header-or-body) turned 15 of the
    30 tests red, reporting 12.95/s at 26 fps, 9.95 at 20 and 4.95 at 10 — against 13.05, 10.00
    and 5.00 measured by hand against the live stack. Restoring it returned 30/30.

  • FrameBufferTests: framing coverage that never existed — split frames, split headers, a
    partial tail across a compaction, growth for a body larger than the buffer, and rejection of
    zero, negative and over-cap length prefixes.

Changed

  • CI fails a headless run that executed zero tests. dotnet test exits 0 when it matched
    no tests at all — an empty filter, a project that compiled to nothing, an adapter that failed
    to load — so the new job reads the .trx counters instead of the exit code. This is the same
    rule the Unity job already applies, for the same reason: that job ran green over zero tests
    for this repository's entire history.

  • Snapshot receive path allocates roughly half of what it did (Cuvara/IndieRPGMMOAdventure#61).
    Decoding one snapshot built four copies of the entity list; two of them are now reused.

    ProtobufWireCodec gained ProtobufWireCodec.CreatePooled(), which pools the decoded
    SnapshotMessage and its entity and event objects across calls, and WireConnection now
    builds its inbound Protobuf decoder that way. The default constructor is unchanged and
    still returns a fresh message per decode
    — the pool invalidates the previous decode,
    which is only sound where the frame is consumed before the next one arrives, so it is
    opted into rather than inherited.

    A static factory that sets a field, rather than a constructor overload, and not for style.
    RegisterNetworking registers the type as
    builder.Register<ProtobufWireCodec>(Lifetime.Singleton), and VContainer's TypeAnalyzer
    selects a constructor by reflection, takes the greediest one, and resolves its parameters
    out of the container. Adding a ProtobufWireCodec(bool) overload therefore broke
    RegisterNetworking at resolve time with "Failed to resolve ProtobufWireCodec : No such
    registration of type: System.Boolean"
    — it compiled, and 691 of 692 EditMode tests still
    passed.

    Making that constructor private does not help, which cost a second red run: VContainer
    reflects with BindingFlags.NonPublic included, so the private overload was still
    selected and the identical failure came back. ProtobufWireCodec must therefore have
    exactly one constructor of any accessibility, and it must be parameterless — hence a
    settable private field rather than a constructor argument. A reflection test asserts this
    outside Unity, with NonPublic in the mask, so the pure-C# suites catch it.

    WireConnection builds its own inbound codec even when the outbound codec is already
    Protobuf, instead of aliasing it as before. That is a correctness fix riding along: the
    outbound codec is a DI singleton shared by the gateway and game-session connections,
    so a decode buffer on it would be shared between two read loops.

    WorldState.Apply reuses its EntitySnapshotData[] and string[] conversion buffers,
    but only when the new length matches the previous one exactly. The comment that used
    to explain why the arrays were allocated fresh was right and still is: SnapshotData
    carries an array and no count and SnapshotMerger iterates all of it, so a longer buffer
    would replay its tail — entities resurrected at last tick's positions, after a despawn.
    Clearing the tail is worse, not better: a zeroed EntitySnapshotData has a null id and
    the merger would key its dictionary on it. So the win is conditional on the entity count
    repeating, which a settled AOI does and a churning one does not.

    EncodeBody wraps the encoded payload with UnsafeByteOperations.UnsafeWrap instead of
    copying it with ByteString.CopyFrom. The buffer is allocated one line above, never
    published and never written again, so the aliasing that call normally warns about cannot
    arise.

    Measured with GC.GetAllocatedBytesForCurrentThread() around 2,000 decode→resolve→apply
    iterations, 50 entities per delta, Release, same machine, origin/develop and this branch
    built into separate output directories:

    path before after
    full pipeline, steady entity count 18,208 B 10,512 B −42.3%
    full pipeline, varying entity count 10,025 B 7,353 B −26.7%
    DecodeBody alone 12,560 B 7,688 B −38.8%
    WorldState.Apply alone, steady count 2,824 B 0 B −100%
    EncodeBody(InputMessage), per input frame 400 B 360 B −10.0%

    Two allocations named in the issue were left alone deliberately. The resolver's
    List<ResolvedEntity> is published to SnapshotReceived subscribers outside this
    package, and recycling a list handed to an unknown consumer is not a change that can be
    made from inside it. The per-frame new byte[length] on the receive path —
    TcpTransport's until #168 moved it into FrameBuffer.TryTakeFrame — is poolable in
    principle, since neither codec retains it (the Protobuf parser copies into its own
    ByteStrings and the JSON one goes through Utf8.GetString). But
    ITransport.ReadFrameAsync returns a byte[] and no length, so pooling it means changing
    that signature and every implementation and caller. That is a wider change than this one,
    and it is the smaller win of the two.

    SnapshotPipelineReuseTests is not in Tests~/Headless, although it is pure C#.
    WorldState, ResolvedEntity and Msg.EntitySnapshot all name Shared.GameLogic types,
    and that assembly arrives as a UPM git dependency Unity resolves into PackageCache, so
    dotnet restore has nothing to fetch — adding the file to that project fails with
    CS0246: The type or namespace name 'Shared' could not be found (verified, not assumed).
    Giving the headless gate access to Shared.GameLogic is its own piece of work.

    A third claim in the issue was already false: SnapshotResolver's pending list is
    lazily allocated and has been since before the issue was filed. It is not allocated when
    empty.

    SnapshotPipelineReuseTests covers the reuse from the only side that can fail silently —
    content, never allocation counts. Each test decodes or applies at least twice, with a
    smaller and differently valued second frame, because a single-shot test passes against a
    completely broken pool.