Skip to content

Phase 2: Ping walking-skeleton (TcrConnection + handshake + Ping end-to-end) - #8

Merged
TomiCheng merged 8 commits into
developfrom
feat/phase-2-ping
May 9, 2026
Merged

TomiCheng merged 8 commits into
developfrom
feat/phase-2-ping

Conversation

@TomiCheng

Copy link
Copy Markdown
Owner

Summary

Closes Phase 2 of the Geode .NET client roadmap (CLAUDE.md). The
walking skeleton now opens a TCP connection, runs the full 14-step
Geode handshake, and successfully Pings a real apachegeode/geode
server end-to-end (~150 ms in the integration test).

What landed

Transport

  • TcrConnection (internal sealed): primary-constructor injects
    ILogger<>, IOptions<GeodeClientOptions>,
    ClientProxyMembershipIdBuilder.
  • ConnectAsync(host, port, ct) bundles TCP connect + handshake so
    callers cannot forget the handshake.
  • SendAsync(ReadOnlyMemory<byte>) / ReceiveAsync() for raw frame
    bytes; SendRequestAsync(TcrMessage) is the message-level
    composition point that all operations use.
  • IAsyncDisposable with stream-then-socket order ready for Phase 8
    TLS close-notify.

Handshake

  • All 14 wire steps written in field order taken from
    ClientSideHandshakeImpl.write and ServerSideHandshakeImpl
    (server is treated as the authoritative spec; cppcache
    cross-checked but Java wins on disagreements).
  • Step 9 throw is deferred to after step 14 so the server's
    step-13 diagnostic message is folded into the GeodeException
    text; codes 21 (SSL_REQUIRED) and 67 (SERVER_IS_LOCATOR) still
    throw immediately because the server stops sending.
  • Server response captured into _hasServerQueue, _queueSize,
    _serverMember, _deltaEnabled for Phase 6 / 7 / 12+ consumers.

Membership ID — programmatic, not Wireshark blob

  • ClientProxyMembershipIdBuilder mirrors cppcache
    ClientProxyMembershipIDFactory + initObjectVars.
  • Process-scoped uniqueTag = "Native_" + 10 random alnum + PID
    generated once at type-load via RandomNumberGenerator.GetInt32
    (cryptographic, not Random.Shared).
  • DNS / PID inputs from BCL (Dns.GetHostName,
    Dns.GetHostAddresses, Environment.ProcessId) — no native bits.
  • Identity bytes cached after first Build().

Wire primitives

  • BigEndianBinaryWriter.WriteBytes (varint length + bytes).
  • BigEndianBinaryWriter.WriteArrayLen (1/3/5-byte cppcache encoding).
  • BigEndianBinaryWriter.WriteJavaModifiedUtf8 (u16 length + modified
    UTF-8, supplementary plane handled per UTF-16 code unit).
  • BigEndianBinaryWriter.WriteString — Geode-tagged string with the
    DSCode header byte (CacheableASCIIString=87 /
    CacheableString=42 / CacheableNullString=69). Without the
    header byte, server's StaticSerialization.readString throws
    Unknown header byte 0 — this was one of the two bugs that
    blocked the original handshake.

Public API

  • GeodeException (Geode.Client) for protocol-level "server said
    no" failures; BCL exceptions still bubble for I/O and API misuse.
  • GeodeClientOptions + nested PoolOptions / TlsOptions /
    SubscriptionOptions (Geode.Client.Options). 18 fields imported
    from cppcache SystemProperties; statistics / logging /
    heap-LRU / TX timeouts / threadpool omitted in favour of .NET
    equivalents.
  • Subscription.ConflateEvents is bool? tristate (null = server
    default, true = on, false = off) wired into handshake step 7.
  • AddGeodeClient(IConfiguration) extension on IServiceCollection
    in Geode.Client. Registers ClientProxyMembershipIdBuilder as
    singleton, TcrConnection as transient.

PingAsync — operation as extension method

  • Geode.Client.Protocol.Operations.PingExtensions.PingAsync( this TcrConnection, CT) composes SendRequestAsync with
    MessageType.Ping (TransactionId = -1, no parts) and asserts the
    reply is MessageType.Reply (6).
  • Lives as an extension so TcrConnection stays focused on
    transport; future PutExtensions / GetExtensions follow the
    same shape.

Tests

  • Geode.Client.Tests: 71 unit tests pass, including 9 new
    structural tests for ClientProxyMembershipIdBuilder that decode
    the identity blob and assert on every field. Includes an explicit
    regression guard that the durable section is written even when
    DurableClientId is empty (the bug that caused the original
    server refusal).
  • Geode.Client.IntegrationTests.PingIntegrationTests: passes
    in ~150 ms
    against apachegeode/geode:latest (1.15.1) via
    Testcontainers. GeodeFixture wraps gfsh in
    sh -c "... && tail -f" so the container does not exit when the
    startup script finishes; wait strategy now polls the server port
    (40404) instead of the locator port.

Verified locally with Podman as the container runtime
(DOCKER_HOST=npipe://./pipe/podman-machine-default,
TESTCONTAINERS_RYUK_DISABLED=true).

CLAUDE.md updated

  • Handshake spec corrected (AcceptanceCode = 59, not 38; full
    field list with ReadTimeout / Overrides[] / SecurityMode /
    server-side Message).
  • Authoritative reference now points at the Java sources rather
    than cppcache.

What's still ahead

  • Phase 3: PutAsync / GetAsync on top of SendRequestAsync,
    same extension-method pattern as PingAsync.
  • Phase 5: surface IGeodeCache / IRegion<,> public API on top.
  • Phase 6: connection pool — at which point membership-ID
    generation, the MetaTransactionId = -1 choice, and the
    extension-method ops layer all become natural fits for refactor.

🤖 Generated with Claude Code

Tomi and others added 8 commits May 8, 2026 23:08
…writer

WriteSByte/ReadSByte plus signed/unsigned 16/32/64-bit integers and IEEE 754
float/double, all big-endian. Geode-specific encodings (Bytes / ArrayLen /
JavaModifiedUtf8 / Utf16Huge) still NotImplementedException — they need a
read of cppcache before being filled in.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds the request/response side of Phase 2 end-to-end on the wire (still
needs Wireshark verification + a real-server integration test before it
can claim to interop with apachegeode/geode).

Transport (TcrConnection)
- ConnectAsync (TCP connect + Geode handshake bundled, NoDelay set)
- SendAsync(ReadOnlyMemory<byte>) — pure transport
- ReceiveAsync — 17-byte header + body, returns the framed bytes
- IAsyncDisposable; ILogger<TcrConnection> + IOptions<GeodeClientOptions>
  injected via primary constructor

Handshake (HandshakeAsync, internal)
- 14 wire steps total — 8 client→server, 6 server→client.
- Field order taken from Java ClientSideHandshakeImpl.write +
  ServerSideHandshakeImpl. cppcache cross-checked but Java wins on
  conflicts.
- ClientProxyMembershipID written via DataSerializer.writeObject framing
  (FixedIDByte + DSFid 38 + varint identity + i32 uniqueId), not as a
  single opaque blob.
- AcceptanceCode (server step 9): immediate throw on 21 SSL_REQUIRED /
  67 SERVER_IS_LOCATOR (server stops sending); other non-OK codes are
  deferred so the diagnostic Message from step 13 can be surfaced in
  the GeodeException.
- Server response captured into _hasServerQueue / _queueSize /
  _serverMember / _deltaEnabled fields for Phase 6/7/12+ consumers.

Membership ID generation (ClientProxyMembershipIdBuilder)
- Programmatic, not Wireshark-captured. Mirrors cppcache
  ClientProxyMembershipIDFactory + initObjectVars.
- Process-scoped uniqueTag = "Native_<10 alnum><PID>", generated once at
  type-load. Builder caches the identity bytes after first Build().
- Reads hostname/IP via Dns.* and PID via Environment.ProcessId.
- Durable subscription path throws NotImplementedException — needs
  CacheableInt32::toData wrapper that lands with subscriptions in
  Phase 12+. Default options keep DurableClientId empty so the throw
  stays unreachable.

Wire primitives (BigEndianBinaryWriter)
- WriteBytes: varint length (via WriteArrayLen) + bytes; null sentinel.
- WriteArrayLen: 1/3/5-byte encoding matching cppcache writeArrayLen.
- WriteJavaModifiedUtf8: u16 length + modified-UTF-8 bytes; supplementary
  code points fall out as 6 bytes via per-char encoding (matches Java
  spec). Two-pass, zero allocation.

Public API
- GeodeException (top-level Geode.Client) for protocol-level "server said
  no" failures. BCL exceptions still bubble for I/O and API misuse.
- GeodeClientOptions + nested PoolOptions / TlsOptions /
  SubscriptionOptions in Geode.Client.Options. 18 fields imported from
  cppcache SystemProperties; statistics/logging/heap-LRU/etc. omitted
  in favour of .NET-native equivalents.

CLAUDE.md
- Handshake spec corrected. AcceptanceCode is 59 (not 38), full field
  list including ReadTimeout / Overrides / SecurityMode / server-side
  Message. Authoritative reference pointed at the Java sources rather
  than cppcache.

Not yet: Wireshark / Java-client byte-for-byte comparison, PingAsync
operation, integration test against apachegeode/geode, and the DI
extension AddGeodeClient (Phase 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pulls in Xunit.DependencyInjection 11.2.1 so upcoming
TcrConnection tests can resolve ILogger / IOptions /
ClientProxyMembershipIdBuilder via a Startup-style fixture
instead of constructing them by hand in every test.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Subscription.ConflateEvents is now read from GeodeClientOptions and
mapped to the handshake "overrides" byte instead of being hardcoded
to 0. Drives the type from string ("server"/"true"/"false") to bool?
so the tristate is type-safe in C# and binds cleanly from
appsettings.json:

    null  → 0 (server default)
    true  → 1 (force conflation on)
    false → 2 (force conflation off)

TcrConnection
- New private MapConflateEvents helper that reads
  _options.Value.Subscription.ConflateEvents directly.
- Step 7 calls it instead of writing a hardcoded 0.
- _options field comment updated — it's now a real consumer rather
  than a CS9113 placeholder.
- Step 13 doc cleaned: the deferred-throw plumbing is already in
  place, so the "TODO defer step-9 throw" note is now stale —
  rewritten to describe the actual behaviour (message folded into
  the GeodeException thrown after step 14).
- ReadHandshakeDataAsync doc moved back next to its method body
  (got orphaned above MapConflateEvents during a previous refactor).

Geode.Client.csproj
- Suppress CA1873 ("Avoid potentially expensive logging methods")
  project-wide. We deliberately use plain ILogger.LogTrace /
  LogDebug calls instead of [LoggerMessage] source generators —
  guard noise outweighs the boxing cost on cold log paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three Phase 2 deliverables that together get the walking skeleton
end-to-end against a real Geode server.

1. Membership-ID handshake bytes corrected
   - BigEndianBinaryWriter.WriteString: a Geode-tagged string writer
     that emits a DSCode header byte (CacheableASCIIString = 87,
     CacheableString = 42, CacheableNullString = 69) before the body.
     Mirrors cppcache DataOutput::writeString and is what the server's
     StaticSerialization.readString switches on. The previous
     WriteJavaModifiedUtf8-only path skipped the header byte and made
     the server hit "Unknown header byte 0" on the very first string
     field.
   - ClientProxyMembershipIdBuilder now calls WriteString for hostname,
     dsName, and uniqueTag.
   - Durable-subscription section is written unconditionally (empty
     string + 300s default for non-durable clients) to match
     MemberIdentifierImpl.toData / fromDataPre_GFE_9_0_0_0, which both
     read the two trailing fields every time. The old "if (durable)
     throw; else skip" behaviour corrupted the wire layout for any
     non-durable client.

2. PingAsync as an extension method
   - New file Geode.Client.Protocol.Operations.PingExtensions with
     PingAsync(this TcrConnection, CT). Composes
     TcrConnection.SendRequestAsync and throws GeodeException when the
     reply's MessageType isn't Reply (6).
   - Keeps TcrConnection focused on transport + handshake; future
     ops (Put / Get / Query / …) land alongside in Operations/ rather
     than swelling TcrConnection.

3. AddGeodeClient DI extension
   - GeodeClientServiceCollectionExtensions at the Geode.Client root.
   - Binds GeodeClientOptions from a supplied IConfiguration section.
   - Registers ClientProxyMembershipIdBuilder as a singleton (process-
     scoped uniqueTag and identity-bytes cache must be shared across
     connections) and TcrConnection as transient. Logging is left to
     the host so the Geode client picks up the application's existing
     stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds an end-to-end smoke test that opens a TcrConnection, runs the
14-step handshake, and Pings against a real apachegeode/geode container
spun up via Testcontainers. This is the moment-of-truth verification
for the wire bytes the Phase 2 client emits — passing against the
server beats any number of unit-level mocks.

Two fixture changes were needed to make the apachegeode/geode image
stay alive long enough to test against:

- WithCommand now wraps gfsh in `sh -c "... && tail -f /srv/srv.log"`
  so the container does not exit the moment the gfsh script finishes
  (gfsh's default behaviour kills the forked locator + server on its
  way out). The tail also pipes server log lines through the container
  stdout, which makes diagnosing future handshake failures one
  `podman logs` away.
- WaitStrategy is now `UntilPortIsAvailable(40404)` instead of 10334.
  The server is the last component to come up and is what the test
  actually connects to; waiting on the locator port lets the test
  start before the server is ready.

Also adds the Xunit.DependencyInjection package reference so the test
can resolve TcrConnection via AddGeodeClient + ServiceCollection.

Verified locally with Podman as the container runtime
(DOCKER_HOST=npipe://./pipe/podman-machine-default, RYUK_DISABLED=true);
the test passes in ~150 ms against apachegeode/geode 1.15.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Switches the per-process uniqueTag generation in
ClientProxyMembershipIdBuilder from Random.Shared.Next to
RandomNumberGenerator.GetInt32. cppcache uses std::default_random_engine
seeded from std::random_device which is non-cryptographic; we go one
better since the tag is part of the client's identity on the server's
hash key and a predictable sequence of tags would make collisions /
spoofing easier.

Behaviour-wise the tag still matches the cppcache shape exactly:
"Native_" + 10 alphanumerics + ProcessId. Performance is irrelevant —
GetInt32 is called 10 times once per process at type-load.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds nine xUnit tests that verify the identity blob produced by the
builder against the schema expected by Java
MemberIdentifierImpl.fromDataPre_GFE_9_0_0_0.

Approach: rather than hard-coding expected bytes (impossible — hostname
/ IP / PID vary per machine), the test file embeds a MembershipBlob
parser that walks the 14-field schema and decodes each field. Tests
then assert on recovered values plus that the cursor consumed the
whole blob — any reorder / drop / extra field surfaces as either an
assertion failure or a span slice OOB.

Coverage highlights:
- Outer framing: byte 0 = 1 (FixedIDByte), byte 1 = 92 (DSFid for
  InternalDistributedMember).
- Idempotent Build() returns the same array reference (cache check).
- Full schema decode against default options: SyncCounter=0, DcPort
  =12334, VmKind=13 (LONER), uniqueTag matches `Native_<10 alnum><PID>`,
  trailing version ordinal=125 (ProtocolVersion.Current).
- Options propagation: GeodeClientOptions.Name → dsName,
  Subscription.{DurableClientId,DurableTimeout} → durable fields.
- Regression guard: durable fields are written even when DurableClientId
  is empty (the bug that caused the original "Unknown header byte 0"
  server-side failure during integration testing).
- Process-scope identity: two builder instances share the same uniqueTag.

Resolves the "Geode.Client.Options vs Microsoft.Extensions.Options.Options"
namespace collision via a `using OptionsFactory = ...` alias.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@TomiCheng
TomiCheng merged commit ecb46dc into develop May 9, 2026
@TomiCheng
TomiCheng deleted the feat/phase-2-ping branch May 9, 2026 03:13
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.

1 participant