starpc 0.49.18 → 0.50.0

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 (86) hide show
  1. package/README.md +1 -3
  2. package/cmd/protoc-gen-es-starpc/typescript.ts +11 -6
  3. package/dist/cmd/protoc-gen-es-starpc/typescript.js +6 -5
  4. package/dist/echo/echo.pb.d.ts +1 -1
  5. package/dist/echo/echo.pb.js +3 -3
  6. package/dist/integration/cross-language/ts-client.js +84 -11
  7. package/dist/integration/cross-language/ts-server.js +100 -4
  8. package/dist/mock/index.d.ts +2 -2
  9. package/dist/mock/index.js +2 -2
  10. package/dist/mock/mock.pb.d.ts +1 -1
  11. package/dist/mock/mock.pb.js +3 -3
  12. package/dist/rpcstream/receipt.test.d.ts +1 -0
  13. package/dist/rpcstream/receipt.test.js +41 -0
  14. package/dist/rpcstream/rpcstream.pb.d.ts +1 -1
  15. package/dist/rpcstream/rpcstream.pb.js +14 -8
  16. package/dist/srpc/call-receipt.d.ts +17 -0
  17. package/dist/srpc/call-receipt.js +106 -0
  18. package/dist/srpc/call-receipt.test.d.ts +1 -0
  19. package/dist/srpc/call-receipt.test.js +375 -0
  20. package/dist/srpc/client.d.ts +3 -1
  21. package/dist/srpc/client.js +20 -0
  22. package/dist/srpc/common-rpc.d.ts +13 -3
  23. package/dist/srpc/common-rpc.js +87 -10
  24. package/dist/srpc/handler.d.ts +2 -1
  25. package/dist/srpc/index.d.ts +4 -0
  26. package/dist/srpc/index.js +3 -0
  27. package/dist/srpc/invoker.d.ts +2 -1
  28. package/dist/srpc/invoker.js +2 -2
  29. package/dist/srpc/rpcproto.pb.d.ts +45 -1
  30. package/dist/srpc/rpcproto.pb.js +60 -7
  31. package/dist/srpc/server-invocation.d.ts +17 -0
  32. package/dist/srpc/server-invocation.js +37 -0
  33. package/dist/srpc/server-rpc.js +3 -1
  34. package/echo/echo.pb.go +1 -1
  35. package/echo/echo.pb.ts +6 -5
  36. package/echo/echo_srpc.pb.cpp +1 -1
  37. package/echo/echo_srpc.pb.go +1 -1
  38. package/echo/echo_srpc.pb.hpp +1 -1
  39. package/echo/echo_srpc.pb.rs +1 -1
  40. package/go.mod +13 -12
  41. package/go.sum +24 -24
  42. package/integration/cross-language/go-client/main.go +137 -5
  43. package/integration/cross-language/go-server/fixture-owner_test.go +127 -0
  44. package/integration/cross-language/go-server/main.go +169 -4
  45. package/integration/cross-language/run.bash +117 -13
  46. package/integration/cross-language/ts-client.ts +94 -13
  47. package/integration/cross-language/ts-server.ts +115 -7
  48. package/mock/index.ts +2 -2
  49. package/mock/mock.pb.go +1 -1
  50. package/mock/mock.pb.ts +6 -5
  51. package/mock/mock_srpc.pb.cpp +1 -1
  52. package/mock/mock_srpc.pb.go +1 -1
  53. package/mock/mock_srpc.pb.hpp +1 -1
  54. package/mock/mock_srpc.pb.rs +1 -1
  55. package/package.json +19 -26
  56. package/srpc/accept.go +1 -1
  57. package/srpc/call-receipt-e2e_test.go +111 -0
  58. package/srpc/call-receipt.go +112 -0
  59. package/srpc/call-receipt.test.ts +441 -0
  60. package/srpc/call-receipt.ts +131 -0
  61. package/srpc/call-receipt_test.go +536 -0
  62. package/srpc/client-rpc.go +1 -8
  63. package/srpc/client.ts +29 -2
  64. package/srpc/common-rpc.go +114 -29
  65. package/srpc/common-rpc.ts +104 -13
  66. package/srpc/handler.ts +2 -0
  67. package/srpc/index.ts +4 -0
  68. package/srpc/invoker.ts +10 -5
  69. package/srpc/msg-stream.go +8 -0
  70. package/srpc/muxed-conn.go +9 -4
  71. package/srpc/muxed-conn_test.go +79 -0
  72. package/srpc/muxed-yamux.go +1 -1
  73. package/srpc/rpcproto.pb.cc +15 -4
  74. package/srpc/rpcproto.pb.go +97 -1
  75. package/srpc/rpcproto.pb.h +59 -0
  76. package/srpc/rpcproto.pb.rs +45 -0
  77. package/srpc/rpcproto.pb.ts +90 -27
  78. package/srpc/rpcproto.proto +16 -0
  79. package/srpc/schema-ownership_test.go +115 -0
  80. package/srpc/server-invocation.go +40 -0
  81. package/srpc/server-invocation.ts +76 -0
  82. package/srpc/server-rpc.go +1 -1
  83. package/srpc/server-rpc.ts +6 -2
  84. package/srpc/stream-yamux.go +1 -1
  85. package/srpc/stream.go +20 -0
  86. package/srpc/websocket.go +1 -1
@@ -0,0 +1,127 @@
1
+ package main
2
+
3
+ import (
4
+ "go/ast"
5
+ "go/parser"
6
+ "go/token"
7
+ "os"
8
+ "strconv"
9
+ "testing"
10
+ )
11
+
12
+ // forbiddenFixtureCalls names the pipeline constructors that rebuild a second
13
+ // srpc server inside a fixture. The receipt post-mortem removed a hand-built
14
+ // ReadPump pipeline; the fixture must drive one srpc.Server through one
15
+ // Server.HandleStream and observe framed packets by decorating net.Conn.
16
+ var forbiddenFixtureCalls = []string{"NewServerRPC", "NewPacketReadWriter", "ReadPump"}
17
+
18
+ // fixtureOwnerViolations returns the ways a cross-language Go fixture source
19
+ // diverges from the single production server owner.
20
+ func fixtureOwnerViolations(src string) []string {
21
+ fset := token.NewFileSet()
22
+ file, err := parser.ParseFile(fset, "fixture.go", src, 0)
23
+ if err != nil {
24
+ return []string{"parse error: " + err.Error()}
25
+ }
26
+
27
+ seen := map[string]bool{}
28
+ handleStreamCalls := 0
29
+ newServerCalls := 0
30
+ ast.Inspect(file, func(n ast.Node) bool {
31
+ call, ok := n.(*ast.CallExpr)
32
+ if !ok {
33
+ return true
34
+ }
35
+ sel, ok := call.Fun.(*ast.SelectorExpr)
36
+ if !ok {
37
+ return true
38
+ }
39
+ switch sel.Sel.Name {
40
+ case "HandleStream":
41
+ handleStreamCalls++
42
+ case "NewServer":
43
+ newServerCalls++
44
+ case "NewServerRPC", "NewPacketReadWriter", "ReadPump":
45
+ seen[sel.Sel.Name] = true
46
+ }
47
+ return true
48
+ })
49
+
50
+ var violations []string
51
+ for _, name := range forbiddenFixtureCalls {
52
+ if seen[name] {
53
+ violations = append(violations, "reconstructs server pipeline via "+name)
54
+ }
55
+ }
56
+ if newServerCalls != 1 {
57
+ violations = append(violations, "expected exactly one srpc.NewServer, found "+strconv.Itoa(newServerCalls))
58
+ }
59
+ if handleStreamCalls != 1 {
60
+ violations = append(violations, "expected exactly one Server.HandleStream entrypoint, found "+strconv.Itoa(handleStreamCalls))
61
+ }
62
+ if !decoratesNetConn(file) {
63
+ violations = append(violations, "no fixture type decorates net.Conn")
64
+ }
65
+ return violations
66
+ }
67
+
68
+ // decoratesNetConn reports whether the source embeds net.Conn in a struct, the
69
+ // only sanctioned way for fixture instrumentation to observe framed packets.
70
+ func decoratesNetConn(file *ast.File) bool {
71
+ found := false
72
+ ast.Inspect(file, func(n ast.Node) bool {
73
+ st, ok := n.(*ast.StructType)
74
+ if !ok {
75
+ return true
76
+ }
77
+ for _, field := range st.Fields.List {
78
+ if len(field.Names) != 0 {
79
+ continue
80
+ }
81
+ sel, ok := field.Type.(*ast.SelectorExpr)
82
+ if !ok {
83
+ continue
84
+ }
85
+ pkg, ok := sel.X.(*ast.Ident)
86
+ if ok && pkg.Name == "net" && sel.Sel.Name == "Conn" {
87
+ found = true
88
+ }
89
+ }
90
+ return true
91
+ })
92
+ return found
93
+ }
94
+
95
+ // TestFixtureUsesSingleServerOwner proves the shipped fixture drives one
96
+ // srpc.Server through one Server.HandleStream and decorates net.Conn.
97
+ func TestFixtureUsesSingleServerOwner(t *testing.T) {
98
+ src, err := os.ReadFile("main.go")
99
+ if err != nil {
100
+ t.Fatalf("read main.go: %v", err)
101
+ }
102
+ if violations := fixtureOwnerViolations(string(src)); len(violations) != 0 {
103
+ t.Fatalf("fixture diverged from single server owner: %v", violations)
104
+ }
105
+ }
106
+
107
+ // TestFixtureGuardDetectsSecondPipeline proves the guard fires when a fixture
108
+ // rebuilds a second RPC server pipeline.
109
+ func TestFixtureGuardDetectsSecondPipeline(t *testing.T) {
110
+ bad := `package main
111
+
112
+ import (
113
+ "net"
114
+
115
+ "github.com/aperturerobotics/starpc/srpc"
116
+ )
117
+
118
+ func run(conn net.Conn) {
119
+ prw := srpc.NewPacketReadWriter(conn)
120
+ rpc := srpc.NewServerRPC(nil, nil)
121
+ _ = rpc.ReadPump(prw)
122
+ }
123
+ `
124
+ if violations := fixtureOwnerViolations(bad); len(violations) == 0 {
125
+ t.Fatal("guard failed to flag a second server pipeline")
126
+ }
127
+ }
@@ -2,10 +2,13 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/binary"
5
6
  "fmt"
6
7
  "net"
7
8
  "os"
8
9
  "os/signal"
10
+ "sync"
11
+ "sync/atomic"
9
12
 
10
13
  "github.com/aperturerobotics/starpc/echo"
11
14
  "github.com/aperturerobotics/starpc/srpc"
@@ -15,14 +18,74 @@ func main() {
15
18
  ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
16
19
  defer cancel()
17
20
 
21
+ receiptMode := len(os.Args) > 1 && os.Args[1] == "receipt"
22
+ receiptCase := ""
23
+ if receiptMode {
24
+ if len(os.Args) < 3 {
25
+ fmt.Fprintln(os.Stderr, "usage: go-server receipt <case>")
26
+ os.Exit(1)
27
+ }
28
+ receiptCase = os.Args[2]
29
+ switch receiptCase {
30
+ case "commit", "abort", "loss", "bare-close":
31
+ default:
32
+ fmt.Fprintf(os.Stderr, "unknown receipt case: %s\n", receiptCase)
33
+ os.Exit(1)
34
+ }
35
+ }
18
36
  mux := srpc.NewMux()
19
37
  echoServer := echo.NewEchoServer(mux)
20
38
  if err := echo.SRPCRegisterEchoer(mux, echoServer); err != nil {
21
39
  fmt.Fprintf(os.Stderr, "register error: %v\n", err)
22
40
  os.Exit(1)
23
41
  }
24
- server := srpc.NewServer(mux)
25
-
42
+ var receiptDone <-chan struct{}
43
+ var finishReceipt func()
44
+ var receiptCommitted atomic.Bool
45
+ if receiptMode {
46
+ done := make(chan struct{})
47
+ var doneOnce sync.Once
48
+ receiptDone = done
49
+ finishReceipt = func() {
50
+ doneOnce.Do(func() {
51
+ close(done)
52
+ })
53
+ }
54
+ }
55
+ var invoker srpc.Invoker = mux
56
+ if receiptMode {
57
+ invoker = srpc.InvokerFunc(func(
58
+ serviceID, methodID string,
59
+ strm srpc.Stream,
60
+ ) (bool, error) {
61
+ handled, err := mux.InvokeMethod(serviceID, methodID, strm)
62
+ if err != nil || !handled {
63
+ return handled, err
64
+ }
65
+ invocation, ok := srpc.GetServerInvocation(strm.Context())
66
+ if !ok {
67
+ return true, context.Canceled
68
+ }
69
+ kind, waitErr := invocation.WaitTerminal(context.Background())
70
+ markerErr := emitReceiptEvent(
71
+ fmt.Sprintf("SERVER_RECEIPT_TERMINAL %s", terminalName(kind)),
72
+ )
73
+ if waitErr != nil {
74
+ return true, waitErr
75
+ }
76
+ if markerErr != nil {
77
+ return true, markerErr
78
+ }
79
+ if kind == srpc.TerminalKind_TERMINAL_KIND_COMMITTED {
80
+ receiptCommitted.Store(true)
81
+ }
82
+ if kind != srpc.TerminalKind_TERMINAL_KIND_COMMITTED {
83
+ finishReceipt()
84
+ }
85
+ return true, nil
86
+ })
87
+ }
88
+ server := srpc.NewServer(invoker)
26
89
  ln, err := net.Listen("tcp", "127.0.0.1:0")
27
90
  if err != nil {
28
91
  fmt.Fprintf(os.Stderr, "listen error: %v\n", err)
@@ -30,11 +93,18 @@ func main() {
30
93
  }
31
94
  defer ln.Close()
32
95
 
96
+ if receiptMode {
97
+ go func() {
98
+ <-receiptDone
99
+ _ = ln.Close()
100
+ }()
101
+ }
102
+
33
103
  fmt.Printf("LISTENING %s\n", ln.Addr().String())
34
104
 
35
105
  go func() {
36
106
  <-ctx.Done()
37
- ln.Close()
107
+ _ = ln.Close()
38
108
  }()
39
109
 
40
110
  for {
@@ -42,6 +112,101 @@ func main() {
42
112
  if err != nil {
43
113
  return
44
114
  }
45
- go server.HandleStream(ctx, conn)
115
+ var stream net.Conn
116
+ stream = conn
117
+ if receiptMode {
118
+ stream = &receiptConn{
119
+ Conn: conn,
120
+ finishReceipt: finishReceipt,
121
+ committed: &receiptCommitted,
122
+ }
123
+ }
124
+ go server.HandleStream(ctx, stream)
125
+ }
126
+ }
127
+
128
+ type receiptConn struct {
129
+ net.Conn
130
+ finishReceipt func()
131
+ committed *atomic.Bool
132
+ inspect []byte
133
+ ackPending bool
134
+ }
135
+
136
+ func (c *receiptConn) Write(p []byte) (int, error) {
137
+ if !c.ackPending {
138
+ if err := c.observeReceiptPackets(p); err != nil {
139
+ return 0, err
140
+ }
141
+ }
142
+ n, err := c.Conn.Write(p)
143
+ if err != nil {
144
+ return n, err
145
+ }
146
+ if c.ackPending && n == len(p) {
147
+ c.finishReceipt()
148
+ c.ackPending = false
149
+ }
150
+ return n, nil
151
+ }
152
+
153
+ func (c *receiptConn) observeReceiptPackets(p []byte) error {
154
+ c.inspect = append(c.inspect, p...)
155
+ for len(c.inspect) >= 4 {
156
+ size := int(binary.LittleEndian.Uint32(c.inspect[:4]))
157
+ if len(c.inspect) < 4+size {
158
+ break
159
+ }
160
+ pkt := &srpc.Packet{}
161
+ if err := pkt.UnmarshalVT(c.inspect[4 : 4+size]); err != nil {
162
+ return err
163
+ }
164
+ c.inspect = c.inspect[4+size:]
165
+ data := pkt.GetCallData()
166
+ if !c.committed.Load() {
167
+ continue
168
+ }
169
+ if data == nil || !data.GetComplete() || data.GetError() != "" {
170
+ continue
171
+ }
172
+ if err := emitReceiptEvent("SERVER_RECEIPT_ACK_WRITE committed"); err != nil {
173
+ return err
174
+ }
175
+ c.ackPending = true
176
+ }
177
+ return nil
178
+ }
179
+
180
+ func terminalName(kind srpc.TerminalKind) string {
181
+ switch kind {
182
+ case srpc.TerminalKind_TERMINAL_KIND_COMMITTED:
183
+ return "committed"
184
+ case srpc.TerminalKind_TERMINAL_KIND_CANCELED:
185
+ return "canceled"
186
+ case srpc.TerminalKind_TERMINAL_KIND_TRANSPORT_LOST:
187
+ return "transportLost"
188
+ case srpc.TerminalKind_TERMINAL_KIND_CLOSED:
189
+ return "closed"
190
+ case srpc.TerminalKind_TERMINAL_KIND_ABANDONED:
191
+ return "abandoned"
192
+ default:
193
+ return "unknown"
194
+ }
195
+ }
196
+
197
+ func emitReceiptEvent(line string) error {
198
+ fmt.Println(line)
199
+ fifo := os.Getenv("RECEIPT_EVENT_FIFO")
200
+ if fifo == "" {
201
+ return nil
202
+ }
203
+ file, err := os.OpenFile(fifo, os.O_WRONLY, 0) //nolint:gosec // FIFO path is created by the local integration runner.
204
+ if err != nil {
205
+ return err
206
+ }
207
+ if _, err := file.WriteString(line + "\n"); err != nil {
208
+ _ = file.Close()
209
+ return err
46
210
  }
211
+ return file.Close()
47
212
  }
@@ -20,6 +20,8 @@ PASSED=0
20
20
  FAILED=0
21
21
  ERRORS=""
22
22
 
23
+ SERVER_LOG=""
24
+
23
25
  # should_run checks if a test name matches the active filters.
24
26
  # Returns 0 (true) if the test should run, 1 (false) otherwise.
25
27
  should_run() {
@@ -77,27 +79,26 @@ RUST_CLIENT="$REPO_DIR/target/release/integration-client"
77
79
  CPP_SERVER="$REPO_DIR/build/cpp-integration-server"
78
80
  CPP_CLIENT="$REPO_DIR/build/cpp-integration-client"
79
81
 
80
- # Start a server and capture its address.
81
- # Sets SERVER_PID and SERVER_ADDR.
82
82
  start_server() {
83
- local addr_file
84
- addr_file=$(mktemp)
85
- "$@" > "$addr_file" 2>&1 &
83
+ SERVER_LOG=$(mktemp)
84
+ "$@" > "$SERVER_LOG" 2>&1 &
86
85
  SERVER_PID=$!
87
86
  # Wait for LISTENING output (up to 3 seconds).
88
87
  local waited=0
89
88
  while [ $waited -lt 30 ]; do
90
- if grep -q 'LISTENING' "$addr_file" 2>/dev/null; then
89
+ if grep -q 'LISTENING' "$SERVER_LOG" 2>/dev/null; then
91
90
  break
92
91
  fi
93
92
  sleep 0.1
94
93
  waited=$((waited + 1))
95
94
  done
96
- SERVER_ADDR=$(grep 'LISTENING' "$addr_file" 2>/dev/null | awk '{print $2}')
97
- rm -f "$addr_file"
95
+ SERVER_ADDR=$(grep 'LISTENING' "$SERVER_LOG" 2>/dev/null | awk '{print $2}')
98
96
  if [ -z "$SERVER_ADDR" ]; then
99
97
  echo "FAILED: server did not start"
100
98
  kill $SERVER_PID 2>/dev/null || true
99
+ wait $SERVER_PID 2>/dev/null || true
100
+ rm -f "$SERVER_LOG"
101
+ SERVER_LOG=""
101
102
  return 1
102
103
  fi
103
104
  return 0
@@ -110,6 +111,27 @@ stop_server() {
110
111
 
111
112
  # run_pair <test_name> <server_args...> -- <client_args...>
112
113
  # The client receives $SERVER_ADDR as its last argument.
114
+ # check_receipt_events verifies terminal markers after the receipt server
115
+ # naturally completes, so receipt synchronization is event/process-backed.
116
+ check_receipt_events() {
117
+ local expected="$1"
118
+ local event_log="$2"
119
+ awk -v expected="$expected" '
120
+ $0 == "SERVER_RECEIPT_TERMINAL " expected { terminal = NR; next }
121
+ $0 == "SERVER_RECEIPT_ACK_WRITE " expected { ack_write = NR; next }
122
+ $0 == "CLIENT_RECEIPT_RESOLVED " expected { client = NR; next }
123
+ END {
124
+ if (!terminal || !client) {
125
+ exit 1
126
+ }
127
+ if (expected == "committed" &&
128
+ (!ack_write || terminal >= ack_write || ack_write >= client)) {
129
+ exit 1
130
+ }
131
+ }
132
+ ' "$event_log"
133
+ }
134
+
113
135
  run_pair() {
114
136
  local test_name="$1"
115
137
  shift
@@ -134,30 +156,104 @@ run_pair() {
134
156
  fi
135
157
  done
136
158
 
159
+ local expected_terminal=""
160
+ case "$test_name" in
161
+ *"receipt commit") expected_terminal="committed" ;;
162
+ *"receipt abort") expected_terminal="canceled" ;;
163
+ *"receipt loss") expected_terminal="transportLost" ;;
164
+ *"receipt bare-close") expected_terminal="closed" ;;
165
+ esac
166
+
137
167
  echo -n " ${test_name}... "
138
168
 
169
+ local event_fifo=""
170
+ local event_log=""
171
+ local event_reader=""
172
+ if [ -n "$expected_terminal" ]; then
173
+ event_fifo=$(mktemp)
174
+ rm -f "$event_fifo"
175
+ mkfifo "$event_fifo"
176
+ event_log=$(mktemp)
177
+ exec 3<>"$event_fifo"
178
+ (
179
+ exec 3>&-
180
+ cat "$event_fifo" > "$event_log"
181
+ ) &
182
+ event_reader=$!
183
+ export RECEIPT_EVENT_FIFO="$event_fifo"
184
+ else
185
+ unset RECEIPT_EVENT_FIFO
186
+ fi
187
+
139
188
  if ! start_server "${srv_args[@]}"; then
140
189
  echo "FAILED (server start)"
141
190
  FAILED=$((FAILED + 1))
142
191
  ERRORS="${ERRORS}\n ${test_name} (server start failed)"
192
+ if [ -n "$event_reader" ]; then
193
+ exec 3>&-
194
+ kill "$event_reader" 2>/dev/null || true
195
+ wait "$event_reader" 2>/dev/null || true
196
+ rm -f "$event_fifo" "$event_log"
197
+ fi
198
+ unset RECEIPT_EVENT_FIFO
143
199
  return
144
200
  fi
145
201
 
146
202
  local client_out
147
203
  client_out=$(mktemp)
204
+ local client_ok=false
205
+ local server_ok=true
148
206
  if "${cli_args[@]}" "$SERVER_ADDR" > "$client_out" 2>&1; then
207
+ client_ok=true
208
+ fi
209
+
210
+ if [ -n "$event_reader" ]; then
211
+ if $client_ok; then
212
+ if ! wait "$SERVER_PID"; then
213
+ server_ok=false
214
+ fi
215
+ else
216
+ stop_server
217
+ wait "$SERVER_PID" 2>/dev/null || true
218
+ server_ok=false
219
+ fi
220
+ exec 3>&-
221
+ wait "$event_reader" 2>/dev/null || true
222
+ else
223
+ stop_server
224
+ fi
225
+ unset RECEIPT_EVENT_FIFO
226
+
227
+ local terminal_ok=true
228
+ if [ -n "$expected_terminal" ] &&
229
+ ! check_receipt_events "$expected_terminal" "$event_log"; then
230
+ terminal_ok=false
231
+ fi
232
+
233
+ if $client_ok && $server_ok && $terminal_ok; then
149
234
  echo "PASSED"
235
+ if [ -n "$expected_terminal" ]; then
236
+ echo " receipt events:"
237
+ sed 's/^/ /' "$event_log"
238
+ fi
150
239
  PASSED=$((PASSED + 1))
151
240
  else
152
241
  echo "FAILED"
153
242
  FAILED=$((FAILED + 1))
154
243
  ERRORS="${ERRORS}\n ${test_name}"
155
- echo " client output:"
156
- sed 's/^/ /' "$client_out"
244
+ if ! $client_ok; then
245
+ echo " client output:"
246
+ sed 's/^/ /' "$client_out"
247
+ fi
248
+ if ! $terminal_ok; then
249
+ echo " receipt events:"
250
+ sed 's/^/ /' "$event_log"
251
+ echo " server output:"
252
+ sed 's/^/ /' "$SERVER_LOG"
253
+ fi
157
254
  fi
158
- rm -f "$client_out"
159
-
160
- stop_server
255
+ rm -f "$client_out" "$SERVER_LOG" "$event_fifo" "$event_log"
256
+ SERVER_LOG=""
161
257
  }
162
258
 
163
259
  echo ""
@@ -168,6 +264,10 @@ echo ""
168
264
  run_pair "go-server + go-client" "$GO_SERVER" -- "$GO_CLIENT"
169
265
  run_pair "go-server + rust-client" "$GO_SERVER" -- "$RUST_CLIENT"
170
266
  run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT"
267
+ run_pair "go-server + ts-client receipt commit" "$GO_SERVER" receipt commit -- node "$TS_CLIENT" receipt commit
268
+ run_pair "go-server + ts-client receipt abort" "$GO_SERVER" receipt abort -- node "$TS_CLIENT" receipt abort
269
+ run_pair "go-server + ts-client receipt loss" "$GO_SERVER" receipt loss -- node "$TS_CLIENT" receipt loss
270
+ run_pair "go-server + ts-client receipt bare-close" "$GO_SERVER" receipt bare-close -- node "$TS_CLIENT" receipt bare-close
171
271
  run_pair "go-server + cpp-client" "$GO_SERVER" -- "$CPP_CLIENT"
172
272
 
173
273
  # Rust server combinations
@@ -178,6 +278,10 @@ run_pair "rust-server + cpp-client" "$RUST_SERVER" -- "$CPP_CLIENT"
178
278
 
179
279
  # TypeScript server combinations
180
280
  run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT"
281
+ run_pair "ts-server + go-client receipt commit" node "$TS_SERVER" receipt commit -- "$GO_CLIENT" receipt commit
282
+ run_pair "ts-server + go-client receipt abort" node "$TS_SERVER" receipt abort -- "$GO_CLIENT" receipt abort
283
+ run_pair "ts-server + go-client receipt loss" node "$TS_SERVER" receipt loss -- "$GO_CLIENT" receipt loss
284
+ run_pair "ts-server + go-client receipt bare-close" node "$TS_SERVER" receipt bare-close -- "$GO_CLIENT" receipt bare-close
181
285
  run_pair "ts-server + rust-client" node "$TS_SERVER" -- "$RUST_CLIENT"
182
286
  run_pair "ts-server + ts-client" node "$TS_SERVER" -- node "$TS_CLIENT"
183
287
  run_pair "ts-server + cpp-client" node "$TS_SERVER" -- "$CPP_CLIENT"
@@ -1,4 +1,5 @@
1
1
  import net from 'net'
2
+ import { closeSync, openSync, writeSync } from 'node:fs'
2
3
  import { pipe } from 'it-pipe'
3
4
  import { pushable } from 'it-pushable'
4
5
  import { Client } from '../../srpc/client.js'
@@ -7,9 +8,22 @@ import {
7
8
  prependLengthPrefixTransform,
8
9
  } from '../../srpc/packet.js'
9
10
  import { combineUint8ArrayListTransform } from '../../srpc/array-list.js'
10
- import { runClientTest } from '../../echo/client-test.js'
11
+ import { EchoMsg, runClientTest } from '../../echo/index.js'
11
12
  import type { OpenStreamFunc, PacketStream } from '../../srpc/stream.js'
12
13
  import type { Source } from 'it-stream-types'
14
+ function emitReceiptEvent(line: string): void {
15
+ console.log(line)
16
+ const fifo = process.env.RECEIPT_EVENT_FIFO
17
+ if (!fifo) {
18
+ return
19
+ }
20
+ const fd = openSync(fifo, 'w')
21
+ try {
22
+ writeSync(fd, `${line}\n`)
23
+ } finally {
24
+ closeSync(fd)
25
+ }
26
+ }
13
27
 
14
28
  // tcpSocketToPacketStream wraps a Node.js TCP socket into a PacketStream.
15
29
  function tcpSocketToPacketStream(socket: net.Socket): PacketStream {
@@ -32,8 +46,7 @@ function tcpSocketToPacketStream(socket: net.Socket): PacketStream {
32
46
  source: socketSource(),
33
47
  sink: async (source: Source<Uint8Array>): Promise<void> => {
34
48
  for await (const chunk of pipe(source, prependLengthPrefixTransform())) {
35
- const data =
36
- chunk instanceof Uint8Array ? chunk : (chunk as any).subarray()
49
+ const data = chunk instanceof Uint8Array ? chunk : chunk.subarray()
37
50
  await new Promise<void>((resolve, reject) => {
38
51
  socket.write(data, (err) => {
39
52
  if (err) reject(err)
@@ -47,30 +60,98 @@ function tcpSocketToPacketStream(socket: net.Socket): PacketStream {
47
60
  }
48
61
 
49
62
  async function main() {
50
- const addr = process.argv[2]
63
+ const receiptMode = process.argv[2] === 'receipt'
64
+ const receiptCase = receiptMode ? process.argv[3] : undefined
65
+ const addr = receiptMode ? process.argv[4] : process.argv[2]
51
66
  if (!addr) {
52
- console.error('usage: ts-client <host:port>')
67
+ console.error('usage: ts-client [receipt <case>] <host:port>')
53
68
  process.exit(1)
54
69
  }
70
+ if (
71
+ receiptMode &&
72
+ !['commit', 'abort', 'loss', 'bare-close'].includes(receiptCase ?? '')
73
+ ) {
74
+ throw new Error(`unknown receipt case: ${receiptCase}`)
75
+ }
55
76
 
56
77
  const [host, portStr] = addr.split(':')
57
78
  const port = parseInt(portStr, 10)
79
+ let activeSocket: net.Socket | undefined
58
80
 
59
81
  const openStream: OpenStreamFunc = async (): Promise<PacketStream> => {
60
- return new Promise((resolve, reject) => {
61
- const socket = net.connect(port, host, () => {
62
- resolve(tcpSocketToPacketStream(socket))
63
- })
64
- socket.on('error', reject)
82
+ const { promise, resolve, reject } = Promise.withResolvers<PacketStream>()
83
+ const socket = net.connect(port, host, () => {
84
+ activeSocket = socket
85
+ resolve(tcpSocketToPacketStream(socket))
65
86
  })
87
+ socket.on('error', reject)
88
+ return promise
66
89
  }
67
90
 
68
91
  const client = new Client(openStream)
69
92
 
70
- console.log('Running client test via TCP...')
71
- await runClientTest(client)
93
+ if (receiptMode) {
94
+ console.log(`Running held receipt test via TCP (${receiptCase})...`)
95
+ const request = EchoMsg.create({ body: 'held receipt' })
96
+ const held = await client.requestWithReceipt(
97
+ 'echo.Echoer',
98
+ 'Echo',
99
+ EchoMsg.toBinary(request),
100
+ )
101
+ const result = EchoMsg.fromBinary(held.response)
102
+ if (result.body !== request.body) {
103
+ throw new Error(`expected ${request.body}, got ${result.body}`)
104
+ }
105
+ switch (receiptCase) {
106
+ case 'commit':
107
+ await held.receipt.commit()
108
+ break
109
+ case 'abort':
110
+ await held.receipt.abort()
111
+ break
112
+ case 'loss':
113
+ activeSocket?.resetAndDestroy()
114
+ await expectReceiptFailure(held.receipt)
115
+ break
116
+ case 'bare-close':
117
+ activeSocket?.end()
118
+ await expectReceiptFailure(held.receipt)
119
+ break
120
+ }
121
+ emitReceiptEvent(
122
+ `CLIENT_RECEIPT_RESOLVED ${receiptTerminalName(receiptCase ?? '')}`,
123
+ )
124
+ } else {
125
+ console.log('Running client test via TCP...')
126
+ await runClientTest(client)
127
+ }
72
128
  console.log('All tests passed.')
73
- process.exit(0)
129
+ }
130
+
131
+ function receiptTerminalName(receiptCase: string): string {
132
+ switch (receiptCase) {
133
+ case 'commit':
134
+ return 'committed'
135
+ case 'abort':
136
+ return 'canceled'
137
+ case 'loss':
138
+ return 'transportLost'
139
+ case 'bare-close':
140
+ return 'closed'
141
+ default:
142
+ return 'unknown'
143
+ }
144
+ }
145
+
146
+ async function expectReceiptFailure(receipt: {
147
+ commit(): Promise<void>
148
+ }): Promise<void> {
149
+ try {
150
+ await receipt.commit()
151
+ } catch {
152
+ return
153
+ }
154
+ throw new Error('receipt commit unexpectedly succeeded')
74
155
  }
75
156
 
76
157
  process.on('unhandledRejection', (ev) => {