Phase 2: Ping walking-skeleton (TcrConnection + handshake + Ping end-to-end) - #8
Merged
Merged
Conversation
…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]>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/geodeserver end-to-end (~150 ms in the integration test).
What landed
Transport
TcrConnection(internal sealed): primary-constructor injectsILogger<>,IOptions<GeodeClientOptions>,ClientProxyMembershipIdBuilder.ConnectAsync(host, port, ct)bundles TCP connect + handshake socallers cannot forget the handshake.
SendAsync(ReadOnlyMemory<byte>)/ReceiveAsync()for raw framebytes;
SendRequestAsync(TcrMessage)is the message-levelcomposition point that all operations use.
IAsyncDisposablewith stream-then-socket order ready for Phase 8TLS close-notify.
Handshake
ClientSideHandshakeImpl.writeandServerSideHandshakeImpl(server is treated as the authoritative spec; cppcache
cross-checked but Java wins on disagreements).
step-13 diagnostic message is folded into the
GeodeExceptiontext; codes 21 (SSL_REQUIRED) and 67 (SERVER_IS_LOCATOR) still
throw immediately because the server stops sending.
_hasServerQueue,_queueSize,_serverMember,_deltaEnabledfor Phase 6 / 7 / 12+ consumers.Membership ID — programmatic, not Wireshark blob
ClientProxyMembershipIdBuildermirrors cppcacheClientProxyMembershipIDFactory + initObjectVars.uniqueTag = "Native_" + 10 random alnum + PIDgenerated once at type-load via
RandomNumberGenerator.GetInt32(cryptographic, not
Random.Shared).Dns.GetHostName,Dns.GetHostAddresses,Environment.ProcessId) — no native bits.Build().Wire primitives
BigEndianBinaryWriter.WriteBytes(varint length + bytes).BigEndianBinaryWriter.WriteArrayLen(1/3/5-byte cppcache encoding).BigEndianBinaryWriter.WriteJavaModifiedUtf8(u16 length + modifiedUTF-8, supplementary plane handled per UTF-16 code unit).
BigEndianBinaryWriter.WriteString— Geode-tagged string with theDSCode header byte (
CacheableASCIIString=87/CacheableString=42/CacheableNullString=69). Without theheader byte, server's
StaticSerialization.readStringthrowsUnknown header byte 0— this was one of the two bugs thatblocked the original handshake.
Public API
GeodeException(Geode.Client) for protocol-level "server saidno" failures; BCL exceptions still bubble for I/O and API misuse.
GeodeClientOptions+ nestedPoolOptions/TlsOptions/SubscriptionOptions(Geode.Client.Options). 18 fields importedfrom cppcache
SystemProperties; statistics / logging /heap-LRU / TX timeouts / threadpool omitted in favour of .NET
equivalents.
Subscription.ConflateEventsisbool?tristate (null = serverdefault, true = on, false = off) wired into handshake step 7.
AddGeodeClient(IConfiguration)extension onIServiceCollectionin
Geode.Client. RegistersClientProxyMembershipIdBuilderassingleton,
TcrConnectionas transient.PingAsync— operation as extension methodGeode.Client.Protocol.Operations.PingExtensions.PingAsync( this TcrConnection, CT)composesSendRequestAsyncwithMessageType.Ping(TransactionId = -1, no parts) and asserts thereply is
MessageType.Reply (6).TcrConnectionstays focused ontransport; future
PutExtensions/GetExtensionsfollow thesame shape.
Tests
Geode.Client.Tests: 71 unit tests pass, including 9 newstructural tests for
ClientProxyMembershipIdBuilderthat decodethe identity blob and assert on every field. Includes an explicit
regression guard that the durable section is written even when
DurableClientIdis empty (the bug that caused the originalserver refusal).
Geode.Client.IntegrationTests.PingIntegrationTests: passesin ~150 ms against
apachegeode/geode:latest(1.15.1) viaTestcontainers.
GeodeFixturewrapsgfshinsh -c "... && tail -f"so the container does not exit when thestartup 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
AcceptanceCode = 59, not38; fullfield list with
ReadTimeout/Overrides[]/SecurityMode/server-side
Message).than cppcache.
What's still ahead
PutAsync/GetAsyncon top ofSendRequestAsync,same extension-method pattern as
PingAsync.IGeodeCache/IRegion<,>public API on top.generation, the
MetaTransactionId = -1choice, and theextension-method ops layer all become natural fits for refactor.
🤖 Generated with Claude Code