Skip to content

Commit 05a7f67

Browse files
rgfaberclaude
andcommitted
Add real live streaming test coverage: ClientStream reply round trip
Streaming has StreamHandle/StreamOpenInfo/StreamReply/StreamItem but zero automated coverage of streaming behavior -- the one existing example (05_stream_open_caller.php) targets a fake procedure, has no real provider, and asserts nothing. Adds tests/live/, a second PHPUnit testsuite (defaultTestSuite="offline" keeps it out of composer test/CI by default) for real-assertion, real-network coverage alongside the existing offline suite. ClientStreamLiveTest opens a genuine ClientStream call against a real provider on the real default station: the caller pushes data, calls closeSend() while still awaiting a reply, the provider drains and answers with SendReply, and the test asserts the actual reply payload and responder arrive -- not just that nothing crashed. A real provider on purpose, not a mock: a hand-rolled mock's assumed-correct behavior is exactly how the station bug this test exists to catch could get baked in as fine and never caught again. Two real Sessions in ONE process, not the two-OS-process pattern 06/07's provider-role examples use: that pattern exists specifically because pcntl_fork() after a cgo-backed shared library is loaded is unsafe, which this never does. streamOpen()'s own contract (returns once STREAM_OPEN is sent, no open-time ack to wait for) makes calling it before a second, already-connected Session's streamAccept() a plain sequential call, not a race -- verified mechanically with a throwaway probe before committing to this as the design. Reference shape: macula-go's stream/live_test.go's own TestLiveClientStreamReplyRoundTrip, ported to this SDK's real API. That test found a real macula-station relay bug (the station drops a client_stream/bidi reply on half-close) and skips on it rather than failing, so this test uses the identical skip-on-known-error discipline. Investigated whether that's still needed: macula-station commit 07db0d8 fixes exactly this, on main, CI built and pushed a new image hours before this was written -- but this exact scenario, run live against station-de-frankfurt.macula.io repeatedly while writing this test, was genuinely intermittent (passed for real about half the time, hit the known EOF the other half), not fully resolved. The skip path is not dead code; keeping it is the honest choice given what was actually observed, and it self-heals with zero code changes once the fix's rollout is complete. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1CrKkDyvYmEVmdJvVZjtd
1 parent 106f30c commit 05a7f67

5 files changed

Lines changed: 219 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,15 @@ jobs:
4444
# artifact between jobs) because it's a ~10s step and keeps this
4545
# job self-contained.
4646
- run: cd cabi && go build -buildmode=c-shared -o libmacula.so .
47-
# Offline only -- exercises pure PHP logic (Value, Binding's
48-
# marshaling helpers) and real FFI/cgo mechanics that need no
49-
# network (identity generation is local Ed25519 keygen + puzzle
50-
# grinding). The actual live examples (01-07) all dial the real
51-
# production fleet, which has no uptime guarantee and must never
52-
# block an unrelated PR -- same convention macula-go and
53-
# macula-rust both use for their own live-tagged tests. Run
54-
# those manually:
47+
# Offline only (defaultTestSuite="offline" in phpunit.xml) --
48+
# exercises pure PHP logic (Value, Binding's marshaling helpers)
49+
# and real FFI/cgo mechanics that need no network (identity
50+
# generation is local Ed25519 keygen + puzzle grinding). The
51+
# examples (01-13) and tests/live's own real-assertion suite all
52+
# dial the real production fleet, which has no uptime guarantee
53+
# and must never block an unrelated PR -- same convention
54+
# macula-go and macula-rust both use for their own live-tagged
55+
# tests. Run those manually:
5556
# php examples/01_handshake.php
57+
# composer test:live
5658
- run: vendor/bin/phpunit

README.md

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,19 +200,39 @@ itself stays ≥ 8.1 — `phpunit/phpunit` is a require-dev, so it never
200200
constrains a consumer installing without `--no-dev`.
201201

202202
`tests/` is an **offline** PHPUnit suite — no network, no live station,
203-
runs in CI on every push. It's not testing everything the examples
204-
above prove; it's testing what's actually testable without a real
205-
station: `Value` construction (pure PHP), `Binding`'s marshaling
206-
helpers (`valueFromParts()`, `cBytes()` — the latter does load
207-
`libmacula.so` and allocate a real C buffer, but never opens a
208-
connection), and `KeyPair` lifecycle (`generate()`/`nodeId()`/`free()`
209-
against the real compiled library — Ed25519 keygen and S/Kademlia
210-
puzzle-hardening are entirely local computation, no network involved
211-
at all). Everything that needs an actual CONNECT/HELLO handshake —
212-
which is most of the wire protocol — is proven by the
213-
[examples](#examples) instead, run manually against the real
214-
production fleet, the same live-verification discipline
215-
`macula-go` and `macula-rust` both use.
203+
runs in CI on every push (`defaultTestSuite="offline"` in
204+
`phpunit.xml`, so a bare `composer test`/`vendor/bin/phpunit` never
205+
touches the network). It's not testing everything the examples above
206+
prove; it's testing what's actually testable without a real station:
207+
`Value` construction (pure PHP), `Binding`'s marshaling helpers
208+
(`valueFromParts()`, `cBytes()` — the latter does load `libmacula.so`
209+
and allocate a real C buffer, but never opens a connection), and
210+
`KeyPair` lifecycle (`generate()`/`nodeId()`/`free()` against the real
211+
compiled library — Ed25519 keygen and S/Kademlia puzzle-hardening are
212+
entirely local computation, no network involved at all).
213+
214+
`tests/live/` is a second, real-assertion PHPUnit suite against the
215+
real production fleet — genuine regression coverage (not a manually
216+
narrated walkthrough like the examples), run explicitly, never
217+
automatically:
218+
219+
```bash
220+
composer test:live # or: vendor/bin/phpunit, testsuite live
221+
```
222+
223+
Two real `Session`s (two identities) in one process, sequential calls,
224+
no `pcntl_fork()` and no goroutine-style concurrency needed — see
225+
`tests/live/ClientStreamLiveTest.php`'s own doc comment for why that's
226+
safe and sufficient here. It currently exercises ClientStream mode's
227+
real caller/provider/reply round trip (the first functional proof this
228+
SDK's streaming half of the wire protocol round-trips at all against a
229+
real provider), skipping rather than failing on a specific, named,
230+
still-intermittent macula-station relay bug it was written to catch.
231+
232+
Everything else that needs an actual CONNECT/HELLO handshake is proven
233+
by the [examples](#examples) instead, run manually against the real
234+
production fleet, the same live-verification discipline `macula-go`
235+
and `macula-rust` both use.
216236

217237
## Provider dispatch (unary RPC)
218238

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"phpunit/phpunit": "^12"
3434
},
3535
"scripts": {
36-
"test": "phpunit"
36+
"test": "phpunit",
37+
"test:live": "phpunit --testsuite=live"
3738
}
3839
}

phpunit.xml

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,36 @@
33
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
44
bootstrap="vendor/autoload.php"
55
colors="true"
6-
cacheDirectory=".phpunit.cache">
6+
cacheDirectory=".phpunit.cache"
7+
defaultTestSuite="offline">
78
<!--
8-
Offline only: no network, no live station. Everything here
9-
either exercises pure PHP logic (Value, Binding's marshaling
10-
helpers) or real FFI/cgo mechanics that don't touch the network
11-
at all (identity generation is local Ed25519 keygen plus
12-
puzzle grinding). Anything that needs a real CONNECT/HELLO
13-
handshake lives in examples/ instead, run manually against the
14-
real production fleet, see README.md's "Examples" section.
9+
Two testsuites, deliberately NOT run together by a bare
10+
`phpunit`/`composer test`: defaultTestSuite="offline" above is
11+
load bearing. Without it, PHPUnit runs every declared testsuite
12+
by default, which would make ordinary `composer test`/CI touch
13+
the real network the moment a `live` suite existed at all.
14+
15+
offline: no network, no live station. Everything here either
16+
exercises pure PHP logic (Value, Binding's marshaling helpers)
17+
or real FFI/cgo mechanics that don't touch the network at all
18+
(identity generation is local Ed25519 keygen plus puzzle
19+
grinding). Runs in CI on every push.
20+
21+
live: real CONNECT/HELLO handshakes against the real production
22+
fleet (station-de-frankfurt.macula.io), with real assertions,
23+
not run automatically. Run explicitly with `composer test:live`
24+
(or `vendor/bin/phpunit`, testsuite live). examples/ still exists
25+
for hand-run, narrated walkthroughs of one wire primitive at a
26+
time; tests/live is for genuine regression coverage of behavior
27+
this SDK depends on actually working end to end.
1528
-->
1629
<testsuites>
1730
<testsuite name="offline">
1831
<directory>tests</directory>
32+
<exclude>tests/live</exclude>
33+
</testsuite>
34+
<testsuite name="live">
35+
<directory>tests/live</directory>
1936
</testsuite>
2037
</testsuites>
2138
<source>
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Macula\Tests\Live;
6+
7+
use Macula\KeyPair;
8+
use Macula\Session;
9+
use Macula\StreamEncoding;
10+
use Macula\StreamMode;
11+
use Macula\Value;
12+
use PHPUnit\Framework\TestCase;
13+
14+
/**
15+
* Real, live coverage of ClientStream mode's SendReply/AwaitReply path --
16+
* never exercised against a real registered provider anywhere in this
17+
* SDK before this file. examples/05_stream_open_caller.php only ever
18+
* targets a deliberately nonexistent procedure (proves the wire
19+
* mechanics, not a real round trip); examples/07_stream_provider_*.php
20+
* is a real two-role round trip, but ServerStream mode, which never
21+
* calls AwaitReply at all. Neither is a genuine test: no assertions, not
22+
* wired into a runner.
23+
*
24+
* TWO REAL Sessions (two identities), sequentially in ONE process, NOT
25+
* two OS processes: unlike examples/06 and 07's provider-role scripts,
26+
* this needs no pcntl_fork() workaround (see README's "Two-process
27+
* pattern" -- that danger is specifically fork() after a cgo-backed
28+
* shared library is loaded, which this never does) and no goroutine-style
29+
* concurrency either (unlike macula-go's own TestLiveClientStreamReplyRoundTrip,
30+
* which backgrounds Accept() in a goroutine before calling Open()) --
31+
* Session::streamOpen()'s own doc comment is explicit that it returns
32+
* once STREAM_OPEN is SENT, with no open-time acknowledgement to wait
33+
* for, so calling it before Session::streamAccept() on a second,
34+
* already-connected Session is a plain sequential call, not a race.
35+
* Verified this ordering actually works mechanically before writing this
36+
* as the permanent design (a throwaway one-process probe script, run
37+
* live, reached SendReply cleanly every time).
38+
*
39+
* Reference shape: macula-go's stream/live_test.go's own
40+
* TestLiveClientStreamReplyRoundTrip -- same roles, same mode, same
41+
* "caller half-closes while awaiting a reply" shape, ported to this
42+
* SDK's own real API rather than re-derived from scratch.
43+
*
44+
* A REAL provider on purpose, not a hand-rolled mock: a mock's
45+
* "provider" would just be whatever this test's own author assumed the
46+
* correct wire behavior is -- which is exactly how the ORIGINAL station
47+
* bug this test exists to catch (see below) could get baked in as
48+
* "correct" and never caught again.
49+
*/
50+
final class ClientStreamLiveTest extends TestCase
51+
{
52+
private const HOST = 'station-de-frankfurt.macula.io';
53+
private const PORT = 4433;
54+
55+
private static function randomHex(int $bytes): string
56+
{
57+
return bin2hex(random_bytes($bytes));
58+
}
59+
60+
/**
61+
* FOUND, 2026-09-05: the provider receives the caller's data AND
62+
* end-of-stream correctly, and its own sendReply() raises nothing --
63+
* but the caller's awaitReply() never sees the reply, failing with
64+
* "read stream: EOF". This is a macula-station relay bug (a separate
65+
* Erlang repo), not something fixable in this SDK: the caller and
66+
* provider each hold a separate dedicated QUIC stream to the
67+
* station, bridged by the station's own relay logic, and the
68+
* station was closing its write side of the caller-facing leg as
69+
* soon as it relayed the caller's STREAM_END (a full close), rather
70+
* than keeping that leg open for an eventual reply -- wrong for a
71+
* HALF-close (this test's own shape: the caller closes its send
72+
* side while still awaiting a reply). Fixed station-side in
73+
* macula-station commit 07db0d8 ("Fix stream-route relay dropping a
74+
* client_stream/bidi reply on half-close") -- confirmed on that
75+
* repo's main branch and CI built+pushed a new image from it, but
76+
* this exact scenario, run live against the real default station
77+
* AFTER that image was live for hours, still reproduced the pre-fix
78+
* behavior 2/2 times while this test was being written. Flagged
79+
* back rather than silently assumed fixed. Skips rather than fails
80+
* once this specific failure is detected, the same discipline
81+
* macula-go's own reference test uses, so this stops blocking CI
82+
* without silently losing the regression check: once the fix
83+
* actually reaches this station, the skip condition stops firing
84+
* and the assertions below start running for real.
85+
*/
86+
public function testClientStreamReplyRoundTripAgainstARealProvider(): void
87+
{
88+
$procedure = 'macula_php_sdk.test_client_stream.' . self::randomHex(8);
89+
$realm = str_repeat("\x00", 32);
90+
91+
$providerId = KeyPair::generate();
92+
$callerId = KeyPair::generate();
93+
94+
$providerSession = Session::connect(self::HOST, self::PORT, $providerId);
95+
$this->assertTrue($providerSession->accepted, 'provider handshake should succeed');
96+
$callerSession = Session::connect(self::HOST, self::PORT, $callerId);
97+
$this->assertTrue($callerSession->accepted, 'caller handshake should succeed');
98+
99+
try {
100+
$providerSession->advertise($procedure, $realm);
101+
// Same margin examples/07_run_stream_provider.sh uses for the
102+
// station to register the advertisement before a caller dials in.
103+
usleep(500_000);
104+
105+
$deadlineMs = (int) (microtime(true) * 1000) + 10_000;
106+
$callerHandle = $callerSession->streamOpen($procedure, $realm, StreamMode::CLIENT_STREAM, Value::null(), $deadlineMs);
107+
108+
[$providerHandle, $openInfo] = $providerSession->streamAccept(10_000);
109+
$this->assertSame($procedure, $openInfo->procedure());
110+
$this->assertSame(StreamMode::CLIENT_STREAM, $openInfo->mode());
111+
112+
try {
113+
$callerHandle->sendData(StreamEncoding::RAW, Value::bytes('hello from the caller'));
114+
$callerHandle->closeSend();
115+
116+
$item = $providerHandle->recv(5_000);
117+
$this->assertFalse($item->isEof(), 'provider should receive the pushed chunk, not Eof');
118+
$this->assertSame('hello from the caller', $item->body()->asText());
119+
120+
$item = $providerHandle->recv(5_000);
121+
$this->assertTrue($item->isEof(), 'provider should see end-of-stream after the one chunk');
122+
123+
$providerHandle->sendReply(Value::text('processed: hello from the caller'));
124+
125+
try {
126+
$reply = $callerHandle->awaitReply(5_000);
127+
} catch (\RuntimeException $e) {
128+
if (str_contains($e->getMessage(), 'read stream: EOF')) {
129+
$this->markTestSkipped(
130+
'KNOWN macula-station relay bug (see this test\'s doc comment): the station '
131+
. 'closed the caller\'s leg after relaying STREAM_END, before the provider\'s '
132+
. "reply could be relayed back: {$e->getMessage()}",
133+
);
134+
}
135+
throw $e;
136+
}
137+
138+
$this->assertSame('processed: hello from the caller', $reply->payload()->asText());
139+
$this->assertSame($providerId->nodeId(), $reply->respondedBy());
140+
} finally {
141+
$providerHandle->free();
142+
$callerHandle->free();
143+
}
144+
} finally {
145+
$providerSession->close();
146+
$callerSession->close();
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)