Skip to content

fix: harden RPC cancel path + add 15-case chaos suite#112

Merged
singchia merged 6 commits into
mainfrom
fix/chaos-hardening
Apr 21, 2026
Merged

fix: harden RPC cancel path + add 15-case chaos suite#112
singchia merged 6 commits into
mainfrom
fix/chaos-hardening

Conversation

@singchia

Copy link
Copy Markdown
Owner

Summary

Chaos testing exercise targeting every break-point the existing test suite skips: peer killed mid-stream, half-open links, handler panics, handler-blocks-forever, mass concurrent RPC cancel. Designing the tests to actively sabotage the library surfaced four real RPC-path bugs that would each take down a production peer in different ways. Fixes + new test suite land together.

Four library bugs fixed

  1. Handler panic crashes the whole Endstream.doRPC had no defer recover() around the user handler goroutine. One buggy handler and the process dies.
  2. RPC cancel never reaches the handler on the server — three nested bugs:
    • handleInRequestPacket only populated rpcCancels when the peer sent a deadline; WithCancel-only requests left ctx as context.Background, so the server cancel lookup silently missed every time.
    • handleOut had no switch case for *packet.RequestCancelPacket, so the cancel packet landed on the default branch and was dropped without ever hitting the wire.
    • packet.Decode / DecodeFromReader both missed TypeRequestCancelPacket (0x73), so even if a cancel packet made it on the wire it was rejected as ErrUnsupportedPacket.

Net effect before this PR: any cEnd.Call(ctx, ...) whose ctx was cancelled (not deadlined) silently leaked a server-side handler goroutine forever.

Also fixes a race the -race detector flagged along the way: dialogue.fini no longer nils writeOutCh, which raced writePkt's initial range read without any benefit (senders already check ctx.Done).

15 new chaos tests

Organised under test/chaos/ by attack vector.

Batch A — transport sabotage (kill_peer_test.go):

  • TestPeerKilledMidWrite — peer killed while server is streaming a 4 MB payload.
  • TestPeerKilledMidRead — peer killed while client Read is blocked.
  • TestHalfOpenSendOnly / TestHalfOpenRecvOnly — one direction silently swallowed.
  • TestNetworkBlackhole — both directions drop, TCP still alive.

Batch B — lifecycle edges (dismiss_chaos_test.go):

  • TestKillPeerDuringDismiss — kill in the middle of the 4-way dismiss.
  • TestOpenStreamDuringEndClose — OpenStream racing End.Close.
  • TestReentrantClose — 50 concurrent Close calls on one stream.
  • TestCloseReadRaceOver100Iterations — rapid open/write/close × 100.
  • TestEndCloseWithInflightRPCs — slam End.Close while 10 RPCs are in flight.

Batch E — RPC sabotage (rpc_chaos_test.go):

  • TestHandlerPanics — handler panic, End must survive, second Call still works.
  • TestHandlerBlocksForever — handler blocks on ctx.Done, client ctx cancel must exit it.
  • TestBiRPCDeadlockAvoidance — A handler calls B, B handler calls A.
  • TestRPCCancelThroughReconnect — kill transport mid-Call.
  • TestManySimultaneousRPCCancels — 50 Calls, cancel all at once, every handler exits.

Helper — test/chaos/helpers/chaosconn.go. A net.Conn wrapper with runtime-configurable sabotage: DropRate, CorruptRate, Delay, HalfClose(dir), Blackhole(), Kill(). Plus NewChaosEndPair(t) to splice chaos into server.NewEndWithConn + client.NewEndWithDialer.

Test plan

  • go test ./test/chaos/... — 15/15 pass. Total runtime around 160 s, dominated by the 94 s TestHalfOpenSendOnly waiting out the heartbeat timeout.
  • go test -race ./test/chaos/... — chaos suite race-clean after the fini cleanup. The unrelated go-timer/v2 race still trips under parallel load on ./application/... packages; that is pre-existing and was already scoped out of test.yml Unit Tests in docs: refresh overview image in hero #111.
  • Full regression: go test ./application/... ./multiplexer/... ./packet/... ./test/unit/ ./test/integration/ ./test/regression/ ./test/e2e/ — all green.

singchia and others added 6 commits April 21, 2026 11:37
Designing aggressive chaos tests around RPC (handler panic, handler
blocks forever, in-flight cancel, mass concurrent cancel) uncovered
four independent bugs along the cancel path. Each would manifest as
either a full process crash or an unbounded handler leak on the
server. Fixing them one at a time turned out to be a chain — every
fix peeled back the next.

1. Handler panic took down the End.

   doRPC spawns the user handler in its own goroutine with no
   recovery. A panic in the handler propagated to the goroutine and
   crashed the whole process. Fix: wrap the handler call in a
   defer-recover that logs the panic and sets rsp.err so the caller
   sees a real error instead of the process dying.

2. rpcCancels entry only populated for deadline-bearing requests.

   handleInRequestPacket wrapped ctx with WithDeadline only when the
   peer sent a Deadline; for plain WithCancel (no deadline) it left
   ctx as context.Background and registered nothing. A later
   RequestCancelPacket then could not find the pkt in rpcCancels and
   was silently dropped, leaving the handler blocked on ctx.Done
   forever. Fix: always wrap with WithCancel (or WithDeadline if
   deadline is present) and always register.

3. handleOut had no case for RequestCancelPacket.

   Cancel packets put onto writeInCh hit the default branch of
   handleOut which returns IOSuccess without writing. The cancel
   never reached the dialogue, never made it on the wire, and the
   server never observed it. Fix: add handleOutRequestCancelPacket
   and route TypeRequestCancelPacket to it from handleOut.

4. packet.Decode / DecodeFromReader had no case for TypeRequestCancelPacket.

   Even with fix 3 in place, the encoded bytes landing on the
   server conn layer were rejected as ErrUnsupportedPacket by the
   decoder because the switch on pktHdr.Typ did not include 0x73.
   Fix: add decoder entries for both Decode (buffer) and
   DecodeFromReader (stream) paths.

Also drop the dg.writeOutCh = nil assignment in dialogue.fini — it
raced writePkt initial read and added nothing, since senders
already bail out via sendToWriteOut ctx.Done case.

Test coverage for each bug lands in the companion commit that adds
the chaos suite under test/chaos/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fifteen new chaos tests under test/chaos organised by attack vector.
The suite runs adversarial scenarios the existing chaos/security
tests did not reach — peer killed mid-stream, half-open links,
network blackhole, reentrant close, mass RPC cancel — and is the
harness that turned up the four RPC-path bugs fixed in the previous
commit.

helpers/chaosconn.go

    Runtime-configurable net.Conn wrapper. Per-pair sabotage knobs:
    DropRate, CorruptRate, Delay, HalfClose, Blackhole, Kill. The
    pair is spliced into server.NewEndWithConn and
    client.NewEndWithDialer so chaos injection is transparent to
    the code under test.

kill_peer_test.go (batch A, 5)

    Transport sabotage. Peer killed mid write / mid read, half-open
    send-only / receive-only, full blackhole. Each test bounds
    End.Close to a known ceiling (close completes within 35 s of
    the current closewait timeout) and asserts no goroutine leak
    via harness.AssertNoLeak.

dismiss_chaos_test.go (batch B, 5)

    Lifecycle edges. Kill peer mid-dismiss, OpenStream racing
    End.Close, reentrant Close (50 concurrent Close calls on the
    same stream), 100 rapid open/write/close iterations, End.Close
    with 10 in-flight RPCs.

rpc_chaos_test.go (batch E, 5)

    RPC sabotage. Handler panic — must not take the End down.
    Handler blocks forever — caller ctx must cancel it. Cross
    bidirectional RPC (A handler calls B, B handler calls A) — no
    deadlock. Transport kill mid-Call — Call returns bounded.
    Fifty simultaneous Calls all cancelled at once — every handler
    exits, every Call returns ctx.Err.

All 15 tests pass in isolation and on package-level run. The
suite runs serially (no t.Parallel on kill_peer_test.go) because
several cases hit the 30 s closewait ceiling on a silenced peer
and parallel runs muddy the goroutine snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Batch F (wire attacks), D (flow control), and H (random kill
schedules) turned up two additional defects that the round-1 fixes
in PR 112 had not reached.

1. Decoder allocates payload before checking size.

   packet.DecodeFromReader reads the 14-byte header, switches on type,
   and lets each per-type DecodeFromReader do io.ReadFull(len). For
   adversarial peers that declare e.g. a 500 MiB StreamPacket, the
   server allocates 500 MiB and blocks on a read that never arrives.
   The existing DefaultMaxPacketSize check in conn.readPkt is too late
   — the allocation has already happened.

   Fix: introduce packet.MaxDecodablePacketLen and
   packet.ErrPacketTooLarge; reject oversized declarations right after
   the header is parsed, before any payload-sized allocation. A
   legitimate peer never exceeds the cap because geminio's
   application-layer Write enforces the same 10 MiB limit.

   Test: TestOversizedPacketDeclaration in wire_chaos_test.go.

2. stream.Write hangs forever when the transport dies underneath.

   Two independent issues compound:
   (a) Write held sm.mtx.RLock across its whole body including a
       blocking send on writeInCh. fini() needs sm.mtx.Lock to flip
       streamOK and close monitorStop. With a full writeInCh on a
       dead conn, Write blocks while holding RLock, fini blocks
       waiting for Lock, and monitorStop is never closed — classic
       deadlock, visible as a writer that never notices its peer is
       gone.
   (b) Even if RLock were released first, the select on
       `case sm.writeInCh <- pkt: case <-dlCh:` has no teardown
       escape — a caller without a Write deadline is pinned until
       someone drains writeInCh, which never happens again.

   Fix: release mtx before the blocking select (mirror Read's
   pattern) and add a `case <-sm.monitorStop: return io.EOF` arm so
   Write exits promptly when fini closes the stream, regardless of
   whether a deadline was set.

   Test: TestRandomKillSchedule and
   TestConcurrentKillWithActiveStreams in kill_schedule_test.go.

Test coverage for each bug lands in the companion commit that adds
the round-2 chaos suites (wire_chaos_test.go, flow_control_chaos_test.go,
kill_schedule_test.go, steady_state_test.go).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…edules, soak

Eleven new chaos tests across four files, extending the first round
with attack vectors that round-1 missed.

wire_chaos_test.go (batch F, 4)

    Byte-level sabotage. TestWireBitFlip — 0.1% corruption on a live
    RPC workload, library must survive.
    TestOversizedPacketDeclaration — raw TCP dials the server and
    declares a 500 MiB packet header; decoder must reject without
    allocating or blocking.
    TestTruncatedPacket — declares 100 bytes, sends 10, closes; the
    server read goroutine must exit cleanly.
    TestHeavyCorruptionDuringTraffic — 5% corruption both ways under
    load; End.Close still returns within the known ceiling.

flow_control_chaos_test.go (batch D, 4)

    Backpressure and size limits. TestServerNeverReadsFlood — client
    publishes at rate; must hit backpressure err or ctx timeout, not
    hang.
    TestSingleStreamHugePayload — Write on a 20 MiB buffer must
    reject with ErrPacketTooLarge, not attempt to ship a packet that
    would itself be rejected by the peer's decoder.
    TestManyConcurrentStreams — 200 simultaneous streams, every
    stream opens and drains.
    TestSlowReaderIntegrity — 4 MiB payload transferred with a slow
    reader; byte-for-byte integrity checked at the end.

kill_schedule_test.go (batch H, 3)

    Random-time kills. TestRandomKillSchedule — 20 iterations, each
    with a random delay in [1, 200] ms between traffic start and
    kill.
    TestConcurrentKillWithActiveStreams — 10 streams streaming
    server-to-client, three delayed concurrent kills on the client
    side.
    TestCascadingFailures — interleave dismiss, in-flight RPCs, and
    transport kill.

steady_state_test.go (batch G, 3, short-gated)

    Soak tests. TestGoroutineStableOverManyStreams — 10 000 stream
    cycles; goroutine count must not creep.
    TestMemoryStableUnderMessageLoad — 100 000 messages; heap flat.
    TestKillRestartPeerManyTimes — 20 kill/restart cycles; baseline
    goroutines preserved.

The G batch uses `if testing.Short() { t.Skip() }` so CI stays fast
and nightly runs pick them up by dropping `-short`.

All 34 chaos tests (23 new this round + 11 round-1 + pre-existing
peers) pass on package-level short run in about 160 s, dominated by
heartbeat-bound tests (TestHalfOpenSendOnly 94 s etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 2 chaos tests under -race exposed three overlapping teardown
races and the Write deadlock that was already half-fixed in the
previous commit. Consolidated here into one pass.

1. stream.Write held mtx.RLock across its blocking select on
   writeInCh, so fini could not acquire the exclusive lock to flip
   streamOK — deadlock under a dead peer with a full writeInCh.
   Also, on the send-into-writeInCh path there was no teardown arm
   for a blocked writer to escape.

   Fix: keep mtx.RLock across the body (preserves the send/close
   serialisation that originally prevented a panic on closed channel)
   AND close sm.monitorStop in stream.fini BEFORE grabbing the
   exclusive lock. Writers stalled on a full writeInCh now wake on
   the new `case <-sm.monitorStop: return 0, io.EOF` arm, release
   RLock, and let fini proceed. No deadlock, no closed-channel panic.

2. End.fini wrote `end.tmr = nil`; End embeds *opts, so this stored
   to opts.tmr, which newStream() reads as part of shub setup.
   Under rapid open/close or kill-during-accept that read races the
   nil store.

   Fix: drop the nil assignment. tmr.Close() already releases the
   timer's resources, and GC reclaims the slot when End is
   unreferenced.

3. ServerConn.fini and ClientConn.fini both ended with a multiple
   assignment nilling readInCh/writeInCh/writeOutCh/heartbeat*.
   baseConn.writePkt loads those fields at startup into local
   variables; under fast teardown the nil store races that load.

   Fix: drop the nil assignments in both files for the same reason
   as (2).

All three follow the same theme: setting pointer-typed fields to
nil during teardown is a cosmetic cleanup that races concurrent
readers and adds nothing the GC does not already do.

Verified: TestRandomKillSchedule, TestConcurrentKillWithActiveStreams,
TestPeerKilledMidWrite / Read, TestHalfOpen*, TestNetworkBlackhole,
TestManySimultaneousRPCCancels all pass with -race. Remaining race
warnings in the suite are inside go-timer/v2 scheduler startup,
which is pre-existing and outside this repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@singchia
singchia merged commit da8429b into main Apr 21, 2026
17 checks passed
@singchia
singchia deleted the fix/chaos-hardening branch April 21, 2026 12:03
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