starpc 0.52.0 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/echo/client-test.d.ts +1 -1
  2. package/dist/echo/client-test.js +110 -2
  3. package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
  4. package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
  5. package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
  6. package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
  7. package/dist/integration/cross-language/ts-client.js +50 -37
  8. package/dist/integration/cross-language/ts-server.js +1 -36
  9. package/dist/rpcstream/rpcstream.d.ts +5 -1
  10. package/dist/rpcstream/rpcstream.js +75 -28
  11. package/dist/rpcstream/rpcstream.test.d.ts +1 -0
  12. package/dist/rpcstream/rpcstream.test.js +92 -0
  13. package/dist/srpc/client.js +18 -4
  14. package/dist/srpc/common-rpc.test.js +2 -0
  15. package/dist/srpc/packet-codec.test.d.ts +1 -0
  16. package/dist/srpc/packet-codec.test.js +75 -0
  17. package/dist/srpc/packet.d.ts +1 -1
  18. package/dist/srpc/packet.js +11 -1
  19. package/dist/srpc/server.js +19 -6
  20. package/dist/srpc/server.test.js +43 -0
  21. package/dist/srpc/stream.d.ts +4 -1
  22. package/dist/srpc/stream.js +62 -3
  23. package/dist/srpc/stream.test.js +110 -1
  24. package/dist/srpc/termination.d.ts +27 -0
  25. package/dist/srpc/termination.js +56 -0
  26. package/dist/srpc/termination.test.d.ts +1 -0
  27. package/dist/srpc/termination.test.js +24 -0
  28. package/dist/testdata/packet-codec-vectors.json +64 -0
  29. package/echo/client-test.ts +124 -2
  30. package/echo/echo_pb2.py +40 -0
  31. package/echo/echo_pb2.pyi +13 -0
  32. package/echo/echo_srpc.py +306 -0
  33. package/echo/echo_srpc.pyi +85 -0
  34. package/go.mod +2 -2
  35. package/go.sum +14 -0
  36. package/integration/cross-language/go-client/main.go +79 -3
  37. package/integration/cross-language/python-client.py +146 -0
  38. package/integration/cross-language/python-server.py +140 -0
  39. package/integration/cross-language/run.bash +190 -65
  40. package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
  41. package/integration/cross-language/tcp-packet-stream.ts +121 -0
  42. package/integration/cross-language/ts-client.ts +62 -40
  43. package/integration/cross-language/ts-server.ts +1 -45
  44. package/mock/mock_pb2.py +38 -0
  45. package/mock/mock_pb2.pyi +11 -0
  46. package/mock/mock_srpc.py +71 -0
  47. package/mock/mock_srpc.pyi +27 -0
  48. package/package.json +19 -5
  49. package/srpc/__init__.py +0 -0
  50. package/srpc/client.ts +20 -4
  51. package/srpc/codec.rs +6 -0
  52. package/srpc/common-rpc.test.ts +2 -0
  53. package/srpc/packet-codec-vectors_test.go +195 -0
  54. package/srpc/packet-codec.test.ts +139 -0
  55. package/srpc/packet-rw.go +9 -2
  56. package/srpc/packet.ts +15 -2
  57. package/srpc/py.typed +0 -0
  58. package/srpc/rpcproto_pb2.py +40 -0
  59. package/srpc/rpcproto_pb2.pyi +40 -0
  60. package/srpc/server.test.ts +50 -0
  61. package/srpc/server.ts +22 -6
  62. package/srpc/stream.test.ts +132 -1
  63. package/srpc/stream.ts +65 -9
  64. package/srpc/termination.test.ts +30 -0
  65. package/srpc/termination.ts +70 -0
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ # The checkout supplies generated message packages outside the installed wheel.
4
+ import asyncio
5
+ import sys
6
+ from collections.abc import AsyncIterator
7
+ from pathlib import Path
8
+
9
+ from google.protobuf import empty_pb2
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
12
+
13
+ from echo import echo_pb2
14
+ from echo.echo_srpc import EchoerClient
15
+ from starpc.call import CallCancelledError, RemoteCallError
16
+ from starpc.client import Client
17
+ from starpc.stream import open_tcp_stream
18
+
19
+
20
+ async def main() -> None:
21
+ args = sys.argv[1:]
22
+ nested = args[:1] in (["--nested"], ["--nested-release"])
23
+ strict_nested = args[:1] == ["--nested-release"]
24
+ if nested:
25
+ args = args[1:]
26
+ if len(args) != 1 or ":" not in args[0]:
27
+ raise ValueError("usage: python-client.py [--nested] host:port")
28
+ host, port_text = args[0].rsplit(":", 1)
29
+ port = int(port_text)
30
+ raw_client = Client(lambda: open_tcp_stream(host, port))
31
+ client = EchoerClient(raw_client)
32
+ body = "hello world via python"
33
+ response = await client.echo(echo_pb2.EchoMsg(body=body))
34
+ if response.body != body:
35
+ raise RuntimeError(f"unexpected unary response: {response.body!r}")
36
+ empty_response = await client.echo(echo_pb2.EchoMsg())
37
+ if empty_response.body:
38
+ raise RuntimeError(f"unexpected empty unary response: {empty_response.body!r}")
39
+ received = [
40
+ message
41
+ async for message in client.echo_server_stream(echo_pb2.EchoMsg(body=body))
42
+ ]
43
+ if len(received) != 5 or any(message.body != body for message in received):
44
+ raise RuntimeError("unexpected server-stream response")
45
+ client_stream = await client.echo_client_stream(_one_request(body))
46
+ if client_stream.body != body:
47
+ raise RuntimeError("unexpected client-stream response")
48
+ bidi = client.echo_bidi_stream(_one_request(body))
49
+ initial = await bidi.__anext__()
50
+ if initial.body != "hello from server":
51
+ raise RuntimeError("unexpected bidi initial response")
52
+ echoed = await bidi.__anext__()
53
+ if echoed.body != body:
54
+ raise RuntimeError("unexpected bidi echo")
55
+ try:
56
+ await bidi.__anext__()
57
+ except StopAsyncIteration:
58
+ pass
59
+ else:
60
+ raise RuntimeError("bidi stream did not terminate")
61
+ empty = await client.do_nothing(empty_pb2.Empty())
62
+ if empty.ByteSize() != 0:
63
+ raise RuntimeError("unexpected DoNothing response")
64
+
65
+ unknown = await raw_client.open_call("missing.Service", "Missing")
66
+ try:
67
+ await unknown.finish()
68
+ try:
69
+ await unknown.receive()
70
+ except RemoteCallError:
71
+ pass
72
+ else:
73
+ raise RuntimeError("unknown method did not return a remote error")
74
+ finally:
75
+ await unknown.aclose()
76
+
77
+ cancelled = await raw_client.open_call("echo.Echoer", "EchoBidiStream")
78
+ await cancelled.cancel()
79
+ try:
80
+ await cancelled.wait_closed()
81
+ except CallCancelledError:
82
+ pass
83
+ await cancelled.aclose()
84
+ if nested:
85
+ await run_nested_test(client, strict_nested)
86
+ print("All tests passed.")
87
+
88
+
89
+ async def run_nested_test(client: EchoerClient, strict: bool = False) -> None:
90
+ from starpc.rpcstream import (
91
+ RpcStreamRemoteError,
92
+ build_rpc_stream_open_stream,
93
+ )
94
+
95
+ nested_client = Client(build_rpc_stream_open_stream("test", client.rpc_stream))
96
+ nested_echo = EchoerClient(nested_client)
97
+ body = "hello world via nested python"
98
+ response = await nested_echo.echo(echo_pb2.EchoMsg(body=body))
99
+ if response.body != body:
100
+ raise RuntimeError(f"unexpected nested unary response: {response.body!r}")
101
+
102
+ if strict:
103
+ try:
104
+ await nested_echo.echo(echo_pb2.EchoMsg(body="__nested_error__"))
105
+ except RemoteCallError:
106
+ pass
107
+ else:
108
+ raise RuntimeError("terminal nested error unexpectedly succeeded")
109
+
110
+ if strict:
111
+ missing = Client(build_rpc_stream_open_stream("missing", client.rpc_stream))
112
+ try:
113
+ await missing.open_call("echo.Echoer", "Echo")
114
+ except RpcStreamRemoteError:
115
+ pass
116
+ else:
117
+ raise RuntimeError("unknown component unexpectedly succeeded")
118
+
119
+ terminal = await nested_client.open_call("missing.Service", "Missing")
120
+ try:
121
+ await terminal.receive()
122
+ except RemoteCallError:
123
+ pass
124
+ else:
125
+ raise RuntimeError("unknown nested method unexpectedly succeeded")
126
+ finally:
127
+ await terminal.aclose()
128
+
129
+ cancelled = await nested_client.open_call("echo.Echoer", "EchoBidiStream")
130
+ await cancelled.send(
131
+ echo_pb2.EchoMsg(body="nested later data").SerializeToString(deterministic=True)
132
+ )
133
+ await cancelled.cancel()
134
+ try:
135
+ await cancelled.wait_closed()
136
+ except CallCancelledError:
137
+ pass
138
+ await cancelled.aclose()
139
+
140
+
141
+ async def _one_request(body: str) -> AsyncIterator[echo_pb2.EchoMsg]:
142
+ yield echo_pb2.EchoMsg(body=body)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ asyncio.run(main())
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ # The checkout supplies generated message packages outside the installed wheel.
4
+ import asyncio
5
+ import signal
6
+ import sys
7
+ from collections.abc import AsyncIterator
8
+ from pathlib import Path
9
+
10
+ from google.protobuf import empty_pb2
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
13
+
14
+ from echo import echo_pb2
15
+ from echo.echo_srpc import register_echoer
16
+ from rpcstream import rpcstream_pb2
17
+ from starpc.rpcstream import ComponentRegistry, handle_rpc_stream
18
+ from starpc.server import Server, ServiceRegistry
19
+ from starpc.stream import TCPStreamServer
20
+
21
+
22
+ class Implementation:
23
+ def __init__(self, components: ComponentRegistry) -> None:
24
+ self._components = components
25
+ self._release_task: asyncio.Task[None] | None = None
26
+ self._release_complete = asyncio.Event()
27
+
28
+ async def _release_component(self) -> None:
29
+ try:
30
+ await self._components.unregister("release")
31
+ finally:
32
+ self._release_complete.set()
33
+
34
+ async def wait_for_release(self) -> None:
35
+ task = self._release_task
36
+ if task is not None:
37
+ await task
38
+
39
+ async def echo(self, request: echo_pb2.EchoMsg) -> echo_pb2.EchoMsg:
40
+ if request.body == "__nested_error__":
41
+ raise RuntimeError("nested terminal error")
42
+ if request.body == "__nested_release__":
43
+ if self._release_task is None:
44
+ self._release_task = asyncio.create_task(self._release_component())
45
+ await asyncio.Future()
46
+ if request.body == "__nested_release_status__":
47
+ await self._release_complete.wait()
48
+ await self.wait_for_release()
49
+ return echo_pb2.EchoMsg(body="released")
50
+ return echo_pb2.EchoMsg(body=request.body)
51
+
52
+ async def echo_server_stream(
53
+ self, request: echo_pb2.EchoMsg
54
+ ) -> AsyncIterator[echo_pb2.EchoMsg]:
55
+ for _ in range(5):
56
+ yield echo_pb2.EchoMsg(body=request.body)
57
+ await asyncio.sleep(0.2)
58
+
59
+ async def echo_client_stream(
60
+ self, requests: AsyncIterator[echo_pb2.EchoMsg]
61
+ ) -> echo_pb2.EchoMsg:
62
+ async for request in requests:
63
+ return request
64
+ return echo_pb2.EchoMsg()
65
+
66
+ async def echo_bidi_stream(
67
+ self, requests: AsyncIterator[echo_pb2.EchoMsg]
68
+ ) -> AsyncIterator[echo_pb2.EchoMsg]:
69
+ yield echo_pb2.EchoMsg(body="hello from server")
70
+ async for request in requests:
71
+ yield request
72
+
73
+ def rpc_stream(
74
+ self, requests: AsyncIterator[rpcstream_pb2.RpcStreamPacket]
75
+ ) -> AsyncIterator[rpcstream_pb2.RpcStreamPacket]:
76
+ return handle_rpc_stream(requests, self._components)
77
+
78
+ async def do_nothing(self, request: empty_pb2.Empty) -> empty_pb2.Empty:
79
+ return request
80
+
81
+
82
+ async def main() -> None:
83
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
84
+ registry = ServiceRegistry()
85
+ components = ComponentRegistry()
86
+ await components.register("test", Server(registry))
87
+ await components.register("release", Server(registry))
88
+ implementation = Implementation(components)
89
+ register_echoer(registry, implementation)
90
+ server = TCPStreamServer()
91
+ await server.start("127.0.0.1", port)
92
+ host, bound_port = server.address
93
+ print(f"LISTENING {host}:{bound_port}", flush=True)
94
+ rpc_server = Server(registry)
95
+ tasks: set[asyncio.Task[None]] = set()
96
+ errors: list[BaseException] = []
97
+ stopping = asyncio.Event()
98
+ loop = asyncio.get_running_loop()
99
+ for sig in (signal.SIGINT, signal.SIGTERM):
100
+ loop.add_signal_handler(sig, stopping.set)
101
+
102
+ def observe_task(task: asyncio.Task[None]) -> None:
103
+ tasks.discard(task)
104
+ if not task.cancelled() and (error := task.exception()) is not None:
105
+ errors.append(error)
106
+
107
+ try:
108
+ while not stopping.is_set():
109
+ accepting = asyncio.create_task(server.accept())
110
+ stopped = asyncio.create_task(stopping.wait())
111
+ done, _ = await asyncio.wait(
112
+ {accepting, stopped}, return_when=asyncio.FIRST_COMPLETED
113
+ )
114
+ if stopped in done:
115
+ accepting.cancel()
116
+ await asyncio.gather(accepting, return_exceptions=True)
117
+ break
118
+ stopped.cancel()
119
+ await asyncio.gather(stopped, return_exceptions=True)
120
+ stream = accepting.result()
121
+ task = asyncio.create_task(rpc_server.serve(stream))
122
+ tasks.add(task)
123
+ task.add_done_callback(observe_task)
124
+ finally:
125
+ await server.aclose()
126
+ for task in tasks:
127
+ task.cancel()
128
+ await asyncio.gather(*tasks, return_exceptions=True)
129
+ await implementation.wait_for_release()
130
+ await components.unregister("test")
131
+ await components.unregister("release")
132
+ if errors:
133
+ raise ExceptionGroup("cross-language server tasks failed", errors)
134
+ if components._components or components._retired:
135
+ raise RuntimeError("nested component cleanup is incomplete")
136
+ print("NESTED_CLEAN", flush=True)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ asyncio.run(main())
@@ -1,11 +1,13 @@
1
1
  #!/bin/bash
2
2
  # Cross-language integration tests for starpc.
3
- # Runs all 12 server/client combinations across Go, TypeScript, Rust, and C++.
3
+ # Runs all 21 server/client combinations across Go, TypeScript, Rust, C++, and Python.
4
4
  #
5
5
  # Usage:
6
- # ./run.bash # Run all pairs
7
- # ./run.bash go:ts # Run go-server+ts-client and ts-server+go-client
8
- # ./run.bash go:ts go:rust # Run multiple pair filters
6
+ # ./run.bash # Run all pairs
7
+ # ./run.bash go:ts # Run go-server+ts-client and ts-server+go-client
8
+ # ./run.bash python:go # Run python-server+go-client and go-server+python-client
9
+ # ./run.bash --nested ts:python # Run nested TypeScript/Python pairs
10
+ # ./run.bash --nested go:ts go:python ts:python # Run nested Go/TypeScript/Python pairs
9
11
  set -eo pipefail
10
12
 
11
13
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -14,25 +16,79 @@ REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
14
16
  # Fixes errors with the generated esm using require()
15
17
  ESM_BANNER='import{fileURLToPath}from"node:url";import{dirname}from"node:path";import{createRequire as topLevelCreateRequire}from"node:module";const require=topLevelCreateRequire(import.meta.url);const __filename=fileURLToPath(import.meta.url);const __dirname=dirname(__filename);'
16
18
 
17
- FILTERS=("$@")
19
+ NESTED=false
20
+ FILTERS=()
21
+ for arg in "$@"; do
22
+ if [ "$arg" = "--nested" ]; then
23
+ NESTED=true
24
+ else
25
+ FILTERS+=("$arg")
26
+ fi
27
+ done
28
+
29
+ needs_language() {
30
+ local language="$1"
31
+ if [ ${#FILTERS[@]} -eq 0 ]; then
32
+ return 0
33
+ fi
34
+ for filter in "${FILTERS[@]}"; do
35
+ if [[ ":${filter}:" == *":${language}:"* ]]; then
36
+ return 0
37
+ fi
38
+ done
39
+ return 1
40
+ }
18
41
 
19
42
  PASSED=0
20
43
  FAILED=0
21
44
  ERRORS=""
22
45
 
46
+ SERVER_PID=""
23
47
  SERVER_LOG=""
48
+ SERVER_EXIT_STATUS=0
49
+ CLIENT_PID=""
50
+ CLIENT_OUT=""
24
51
 
25
- # should_run checks if a test name matches the active filters.
52
+ cleanup() {
53
+ if [ -n "${CLIENT_PID:-}" ]; then
54
+ kill "$CLIENT_PID" 2>/dev/null || true
55
+ wait "$CLIENT_PID" 2>/dev/null || true
56
+ CLIENT_PID=""
57
+ fi
58
+ if [ -n "${SERVER_PID:-}" ]; then
59
+ kill "$SERVER_PID" 2>/dev/null || true
60
+ wait "$SERVER_PID" 2>/dev/null || true
61
+ SERVER_PID=""
62
+ fi
63
+ if [ -n "${SERVER_LOG:-}" ]; then
64
+ rm -f "$SERVER_LOG"
65
+ SERVER_LOG=""
66
+ fi
67
+ if [ -n "${CLIENT_OUT:-}" ]; then
68
+ rm -f "$CLIENT_OUT"
69
+ CLIENT_OUT=""
70
+ fi
71
+ }
72
+
73
+ trap cleanup EXIT
74
+ trap 'exit 130' INT
75
+ trap 'exit 143' TERM
76
+
77
+ # should_run checks if a server/client pair matches the active filters.
26
78
  # Returns 0 (true) if the test should run, 1 (false) otherwise.
27
79
  should_run() {
28
80
  local test_name="$1"
29
81
  if [ ${#FILTERS[@]} -eq 0 ]; then
30
82
  return 0
31
83
  fi
84
+ local server_language="${test_name%%-server*}"
85
+ local client_language="${test_name#*+ }"
86
+ client_language="${client_language%%-client*}"
32
87
  for filter in "${FILTERS[@]}"; do
33
88
  local lang1="${filter%%:*}"
34
89
  local lang2="${filter##*:}"
35
- if [[ "$test_name" == *"${lang1}-"* && "$test_name" == *"${lang2}-"* ]]; then
90
+ if { [ "$server_language" = "$lang1" ] && [ "$client_language" = "$lang2" ]; } ||
91
+ { [ "$server_language" = "$lang2" ] && [ "$client_language" = "$lang1" ]; }; then
36
92
  return 0
37
93
  fi
38
94
  done
@@ -42,32 +98,39 @@ should_run() {
42
98
  # Build all binaries.
43
99
  echo "=== Building all integration binaries ==="
44
100
 
45
- echo "Building Go server/client..."
46
- go build -o "$SCRIPT_DIR/go-server/go-server" "$SCRIPT_DIR/go-server/"
47
- go build -o "$SCRIPT_DIR/go-client/go-client" "$SCRIPT_DIR/go-client/"
48
-
49
- echo "Building TypeScript server/client..."
50
- "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-server.ts" \
51
- --bundle --sourcemap --platform=node --format=esm \
52
- --banner:js="$ESM_BANNER" \
53
- --outfile="$SCRIPT_DIR/ts-server.mjs"
54
- "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-client.ts" \
55
- --bundle --sourcemap --platform=node --format=esm \
56
- --banner:js="$ESM_BANNER" \
57
- --outfile="$SCRIPT_DIR/ts-client.mjs"
58
-
59
- echo "Building Rust server/client..."
60
- cargo build --release -p echo-example --bin integration-server --bin integration-client
61
-
62
- echo "Vendoring Go dependencies (needed for C++ build)..."
63
- go mod vendor
64
-
65
- echo "Building C++ server/client..."
66
- mkdir -p "$REPO_DIR/build"
67
- pushd "$REPO_DIR/build" > /dev/null
68
- cmake "$REPO_DIR" -DCMAKE_BUILD_TYPE=Release > /dev/null 2>&1
69
- cmake --build . --target cpp-integration-server cpp-integration-client --parallel > /dev/null 2>&1
70
- popd > /dev/null
101
+ if needs_language go; then
102
+ echo "Building Go server/client..."
103
+ go build -o "$SCRIPT_DIR/go-server/go-server" "$SCRIPT_DIR/go-server/"
104
+ go build -o "$SCRIPT_DIR/go-client/go-client" "$SCRIPT_DIR/go-client/"
105
+ fi
106
+
107
+ if needs_language ts; then
108
+ echo "Building TypeScript server/client..."
109
+ "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-server.ts" \
110
+ --bundle --sourcemap --platform=node --format=esm \
111
+ --banner:js="$ESM_BANNER" \
112
+ --outfile="$SCRIPT_DIR/ts-server.mjs"
113
+ "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-client.ts" \
114
+ --bundle --sourcemap --platform=node --format=esm \
115
+ --banner:js="$ESM_BANNER" \
116
+ --outfile="$SCRIPT_DIR/ts-client.mjs"
117
+ fi
118
+
119
+ if needs_language rust; then
120
+ echo "Building Rust server/client..."
121
+ cargo build --release -p echo-example --bin integration-server --bin integration-client
122
+ fi
123
+
124
+ if needs_language cpp; then
125
+ echo "Vendoring Go dependencies (needed for C++ build)..."
126
+ go mod vendor
127
+ echo "Building C++ server/client..."
128
+ mkdir -p "$REPO_DIR/build"
129
+ pushd "$REPO_DIR/build" > /dev/null
130
+ cmake "$REPO_DIR" -DCMAKE_BUILD_TYPE=Release > /dev/null 2>&1
131
+ cmake --build . --target cpp-integration-server cpp-integration-client --parallel > /dev/null 2>&1
132
+ popd > /dev/null
133
+ fi
71
134
 
72
135
  # Binary paths.
73
136
  GO_SERVER="$SCRIPT_DIR/go-server/go-server"
@@ -78,6 +141,7 @@ RUST_SERVER="$REPO_DIR/target/release/integration-server"
78
141
  RUST_CLIENT="$REPO_DIR/target/release/integration-client"
79
142
  CPP_SERVER="$REPO_DIR/build/cpp-integration-server"
80
143
  CPP_CLIENT="$REPO_DIR/build/cpp-integration-client"
144
+ PYTHON=(uv run --project "$REPO_DIR" python)
81
145
 
82
146
  start_server() {
83
147
  SERVER_LOG=$(mktemp)
@@ -95,8 +159,9 @@ start_server() {
95
159
  SERVER_ADDR=$(grep 'LISTENING' "$SERVER_LOG" 2>/dev/null | awk '{print $2}')
96
160
  if [ -z "$SERVER_ADDR" ]; then
97
161
  echo "FAILED: server did not start"
98
- kill $SERVER_PID 2>/dev/null || true
99
- wait $SERVER_PID 2>/dev/null || true
162
+ kill "$SERVER_PID" 2>/dev/null || true
163
+ wait "$SERVER_PID" 2>/dev/null || true
164
+ SERVER_PID=""
100
165
  rm -f "$SERVER_LOG"
101
166
  SERVER_LOG=""
102
167
  return 1
@@ -105,8 +170,36 @@ start_server() {
105
170
  }
106
171
 
107
172
  stop_server() {
108
- kill $SERVER_PID 2>/dev/null || true
109
- wait $SERVER_PID 2>/dev/null || true
173
+ kill "$SERVER_PID" 2>/dev/null || true
174
+ if wait "$SERVER_PID" 2>/dev/null; then
175
+ SERVER_EXIT_STATUS=0
176
+ else
177
+ SERVER_EXIT_STATUS=$?
178
+ fi
179
+ SERVER_PID=""
180
+ }
181
+
182
+ python_server_passed() {
183
+ local status="$1"
184
+ local log="$2"
185
+ [ "$status" -eq 0 ] && grep -qx "NESTED_CLEAN" "$log"
186
+ }
187
+
188
+ verify_python_server_guard() {
189
+ local log
190
+ log="$(mktemp)"
191
+ printf '%s\n' "NESTED_CLEAN" >"$log"
192
+ if python_server_passed 1 "$log"; then
193
+ rm -f "$log"
194
+ echo "runner guard accepted a nonzero Python server" >&2
195
+ return 1
196
+ fi
197
+ if ! python_server_passed 0 "$log"; then
198
+ rm -f "$log"
199
+ echo "runner guard rejected a clean Python server" >&2
200
+ return 1
201
+ fi
202
+ rm -f "$log"
110
203
  }
111
204
 
112
205
  # run_pair <test_name> <server_args...> -- <client_args...>
@@ -142,13 +235,25 @@ run_pair() {
142
235
  return
143
236
  fi
144
237
 
145
- local client_out
146
- client_out=$(mktemp)
238
+ CLIENT_OUT=$(mktemp)
147
239
  local client_ok=false
148
- if "${cli_args[@]}" "$SERVER_ADDR" > "$client_out" 2>&1; then
240
+ timeout 60 "${cli_args[@]}" "$SERVER_ADDR" > "$CLIENT_OUT" 2>&1 &
241
+ CLIENT_PID=$!
242
+ if wait "$CLIENT_PID"; then
149
243
  client_ok=true
150
244
  fi
245
+ CLIENT_PID=""
151
246
  stop_server
247
+ if $NESTED && [[ "$test_name" == python-server* ]]; then
248
+ if ! verify_python_server_guard; then
249
+ client_ok=false
250
+ echo " Python server result guard regression failed"
251
+ elif ! python_server_passed "$SERVER_EXIT_STATUS" "$SERVER_LOG"; then
252
+ client_ok=false
253
+ echo " Python server failed nested shutdown (status $SERVER_EXIT_STATUS):"
254
+ sed 's/^/ /' "$SERVER_LOG"
255
+ fi
256
+ fi
152
257
 
153
258
  if $client_ok; then
154
259
  echo "PASSED"
@@ -158,9 +263,10 @@ run_pair() {
158
263
  FAILED=$((FAILED + 1))
159
264
  ERRORS="${ERRORS}\n ${test_name}"
160
265
  echo " client output:"
161
- sed 's/^/ /' "$client_out"
266
+ sed 's/^/ /' "$CLIENT_OUT"
162
267
  fi
163
- rm -f "$client_out" "$SERVER_LOG"
268
+ rm -f "$CLIENT_OUT" "$SERVER_LOG"
269
+ CLIENT_OUT=""
164
270
  SERVER_LOG=""
165
271
  }
166
272
 
@@ -168,29 +274,48 @@ echo ""
168
274
  echo "=== Running cross-language integration tests ==="
169
275
  echo ""
170
276
 
171
- # Go server combinations
172
- run_pair "go-server + go-client" "$GO_SERVER" -- "$GO_CLIENT"
173
- run_pair "go-server + rust-client" "$GO_SERVER" -- "$RUST_CLIENT"
174
- run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT"
175
- run_pair "go-server + cpp-client" "$GO_SERVER" -- "$CPP_CLIENT"
176
-
177
- # Rust server combinations
178
- run_pair "rust-server + go-client" "$RUST_SERVER" -- "$GO_CLIENT"
179
- run_pair "rust-server + rust-client" "$RUST_SERVER" -- "$RUST_CLIENT"
180
- run_pair "rust-server + ts-client" "$RUST_SERVER" -- node "$TS_CLIENT"
181
- run_pair "rust-server + cpp-client" "$RUST_SERVER" -- "$CPP_CLIENT"
182
-
183
- # TypeScript server combinations
184
- run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT"
185
- run_pair "ts-server + rust-client" node "$TS_SERVER" -- "$RUST_CLIENT"
186
- run_pair "ts-server + ts-client" node "$TS_SERVER" -- node "$TS_CLIENT"
187
- run_pair "ts-server + cpp-client" node "$TS_SERVER" -- "$CPP_CLIENT"
188
-
189
- # C++ server combinations
190
- run_pair "cpp-server + go-client" "$CPP_SERVER" -- "$GO_CLIENT"
191
- run_pair "cpp-server + rust-client" "$CPP_SERVER" -- "$RUST_CLIENT"
192
- run_pair "cpp-server + ts-client" "$CPP_SERVER" -- node "$TS_CLIENT"
193
- run_pair "cpp-server + cpp-client" "$CPP_SERVER" -- "$CPP_CLIENT"
277
+ if $NESTED; then
278
+ # Nested streams have maintained Go, TypeScript, and Python endpoints.
279
+ run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT" --nested
280
+ run_pair "go-server + python-client" "$GO_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py" --nested
281
+
282
+ run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT" --nested
283
+ run_pair "ts-server + python-client" node "$TS_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py" --nested
284
+
285
+ run_pair "python-server + go-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- "$GO_CLIENT" --nested-release
286
+ run_pair "python-server + ts-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- node "$TS_CLIENT" --nested-release
287
+ else
288
+ # Go server combinations
289
+ run_pair "go-server + go-client" "$GO_SERVER" -- "$GO_CLIENT"
290
+ run_pair "go-server + rust-client" "$GO_SERVER" -- "$RUST_CLIENT"
291
+ run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT"
292
+ run_pair "go-server + cpp-client" "$GO_SERVER" -- "$CPP_CLIENT"
293
+ run_pair "go-server + python-client" "$GO_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py"
294
+
295
+ # Rust server combinations
296
+ run_pair "rust-server + go-client" "$RUST_SERVER" -- "$GO_CLIENT"
297
+ run_pair "rust-server + rust-client" "$RUST_SERVER" -- "$RUST_CLIENT"
298
+ run_pair "rust-server + ts-client" "$RUST_SERVER" -- node "$TS_CLIENT"
299
+ run_pair "rust-server + cpp-client" "$RUST_SERVER" -- "$CPP_CLIENT"
300
+
301
+ # TypeScript server combinations
302
+ run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT"
303
+ run_pair "ts-server + rust-client" node "$TS_SERVER" -- "$RUST_CLIENT"
304
+ run_pair "ts-server + ts-client" node "$TS_SERVER" -- node "$TS_CLIENT"
305
+ run_pair "ts-server + cpp-client" node "$TS_SERVER" -- "$CPP_CLIENT"
306
+ run_pair "ts-server + python-client" node "$TS_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py"
307
+
308
+ # Python server combinations
309
+ run_pair "python-server + go-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- "$GO_CLIENT"
310
+ run_pair "python-server + ts-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- node "$TS_CLIENT" lifecycle
311
+ run_pair "python-server + python-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py"
312
+
313
+ # C++ server combinations
314
+ run_pair "cpp-server + go-client" "$CPP_SERVER" -- "$GO_CLIENT"
315
+ run_pair "cpp-server + rust-client" "$CPP_SERVER" -- "$RUST_CLIENT"
316
+ run_pair "cpp-server + ts-client" "$CPP_SERVER" -- node "$TS_CLIENT"
317
+ run_pair "cpp-server + cpp-client" "$CPP_SERVER" -- "$CPP_CLIENT"
318
+ fi
194
319
 
195
320
  echo ""
196
321
  echo "=== Results: ${PASSED} passed, ${FAILED} failed ==="