Skip to content

jhttp: serve POSTs directly, pass json.RawMessage results through - #1

Closed
cjonas9 wants to merge 15 commits into
mainfrom
raw-message-passthrough
Closed

cjonas9 wants to merge 15 commits into
mainfrom
raw-message-passthrough

Conversation

@cjonas9

@cjonas9 cjonas9 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Note: This branch is superseded by stellar-rpc/pull/1037, which does the work of this branch in-tree.

What

jhttp.Bridge serves POST requests without its in-memory client round trip, and a handler's json.RawMessage result goes to the socket verbatim. Based on upstream's latest release, v1.3.5 (go 1.25.0).

Used by stellar/stellar-rpc#1037 and #1060, which keep calling jhttp.NewBridge. stellar/go-stellar-sdk#6020 uses only the client, which is unchanged.

Changes

  • serve.go (new). This is the only change to the root package apart from the module path.
    • Server.ServeRequests(ctx, []*ParsedRequest) []*Response serves parsed requests on an unstarted server, in request order. Handlers run on ctx under the concurrency limit.
    • On this path only, a non-empty json.RawMessage result is passed through as-is: no re-encoding, validation, or HTML escaping.
    • Response.WriteTo writes the response without copying the result.
  • jhttp.Bridge. POST requests go through ServeRequests on the HTTP request context and are streamed with WriteTo. Content-Length comes from a dry run into a counting writer. server.Local, ID virtualization, and marshalError are removed.
  • jhttp.Getter, tools/. Unchanged from v1.3.5 apart from import paths.

Behavior vs. the old bridge (POST)

  • Handlers are cancelled when the client disconnects, and ServerOptions.NewContext is not used. A handler with side effects that must finish anyway should detach with context.WithoutCancel.
  • CancelRequest has no effect, and bridge traffic is not counted in bytes_read/bytes_written.
  • Handlers see IDs and params as sent, where the old client renumbered IDs and re-encoded params. String IDs are echoed verbatim, so <, >, &, U+2028 and U+2029 are no longer HTML-escaped. The JSON value is the same.
  • Everything else on the wire is identical: status codes, 204 rules, invalid requests answered first, duplicate IDs, and batch framing. This is covered by the existing jhttp suite, which is unchanged apart from upstream's Go 1.27 test fix, and by a differential harness against the upstream bridge (218 cases plus ~490k random batches).

Why

The old bridge re-encoded every result on its way to the socket: it marshaled in invoke, encoded onto an in-memory pipe, parsed twice in the client, and compacted twice. getLatestLedger and getLedgers results exceed 3 MB, and getLatestLedger serves a pre-rendered body.

The table compares upstream v1.3.5 with this PR for one POST over loopback HTTP, with the response body discarded. Allocation counts server and client together. Figures are the median of 8 runs on an M4 Pro with Go 1.26.

result time allocated per call
3 MB struct 35.7 ms → 2.4 ms 25.7 MiB → 5.6 MiB
3 MB json.RawMessage 41.8 ms → 0.42 ms 23.8 MiB → 10 KiB
small struct 55 µs → 41 µs 15 KiB → 10 KiB

🤖 Generated with Claude Code

@cjonas9
cjonas9 force-pushed the raw-message-passthrough branch from 9ea28ad to 4ada2ae Compare October 7, 2026 18:31
@cjonas9
cjonas9 marked this pull request as ready for review October 7, 2026 19:22
Copilot AI balanced review requested due to automatic review settings October 7, 2026 19:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The direct bridge omits the server start time, and the nested tools module still builds against upstream.

2 open findings
What changed in this PR

Adds direct HTTP request dispatch and verbatim json.RawMessage response streaming while migrating the module to the Stellar fork.

Changes:

  • Adds Server.ServeRequests and Response.WriteTo.
  • Reworks POST bridging to bypass the in-memory client.
  • Updates module paths and expands direct-dispatch tests.
File Description
serve.go Implements direct dispatch and streaming responses.
jhttp/​bridge.go Routes POST requests directly to the server.
jrpc2_test.go Tests direct dispatch and response writing.
jhttp/​jhttp_test.go Tests batching, contexts, and raw results.
go.mod Renames the root module.
README.md Updates fork documentation links.
doc.go Updates package documentation link.
server.go Updates channel import path.
client.go Updates channel import path.
regression_test.go Updates fork imports.
internal_test.go Updates fork imports.
examples_test.go Updates fork imports.
code_test.go Updates fork import.
bench_test.go Updates fork imports.
jhttp/​getter.go Updates fork imports.
jhttp/​getter_test.go Updates imports and expected error text.
jhttp/​example_test.go Updates fork imports.
handler/​positional.go Updates root-package import.
handler/​handler.go Updates root-package import.
handler/​handler_test.go Updates fork imports.
handler/​example_test.go Updates fork imports.
channel/​channel_test.go Updates fork import.
channel/​bench_test.go Updates fork import.
server/​loop.go Updates fork imports.
server/​loop_test.go Updates fork imports.
server/​local.go Updates fork imports.
server/​local_test.go Updates fork imports.
server/​example_test.go Updates fork imports.
internal/​testutil/​testutil.go Updates root-package import.
internal/​testutil/​testutil_test.go Updates utility import.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread go.mod
Comment thread jhttp/bridge.go
Client: opts.clientOptions(),
Server: opts.serverOptions(),
}),
srv: jrpc2.NewServer(mux, opts.serverOptions()),
@cjonas9 cjonas9 changed the title Direct HTTP dispatch and raw result passthrough jhttp: serve POSTs directly, pass json.RawMessage results through Oct 7, 2026
@cjonas9 cjonas9 self-assigned this Oct 7, 2026

@tamirms tamirms left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling handlers directly and passing json.RawMessage through is the right fix, and your numbers show it. The part I'd question is the fork. jrpc2 already exports what a direct-dispatch HTTP handler needs: ParseRequests, ParsedRequest.ToRequest, the Assigner, and jrpc2.Error. stellar-rpc already owns the rest of its HTTP stack, so the handler could live there, on unmodified upstream jrpc2.

To check, I wrote one: about 270 lines in a single file. It parses with jrpc2.ParseRequests, calls each handler with pr.ToRequest(), passes a non-empty json.RawMessage through, and writes each result once with a computed Content-Length. Against upstream's jhttp.Bridge, status, headers and body bytes matched on 220,063 requests, on Go 1.26.8 and 1.27.1. That was 63 targeted cases (method and content-type variants, invalid UTF-8, a 400 KB ID, a 50,000-element batch) plus 220,000 random batches. Your differential harness would be a good independent check. It runs at the same speed as this PR (Go 1.26.8, M1 Max, one warm POST over loopback with the body discarded, median of 8 runs):

upstream bridge this PR in-tree on upstream jrpc2
3 MiB struct 55.3 ms, 27.3 MiB 4.00 ms, 5.9 MiB 3.93 ms, 6.0 MiB
3 MiB json.RawMessage 63.1 ms, 24.7 MiB 0.54 ms, 10 KiB 0.57 ms, 11 KiB
small struct 80 µs 55 µs 53 µs

In-tree avoids what the fork costs:

  • The module rename is why stellar/go-stellar-sdk#6020 exists, and that PR changes the error type SDK users match with errors.As.
  • This branch is based on v1.3.5, and serve.go no longer compiles against upstream main, where inboundRequestKey and serverKey became mctx keys.
  • Upstreaming would need a second execution path in Server. When this came up in creachadair#99, the maintainer suggested plain HTTP handlers instead of the Bridge.

It also lets the handler own the global request deadline, which removes the duration limiter's copy of every response. I put the numbers in a comment on stellar/stellar-rpc#1037.

If that sounds right, this PR and stellar/go-stellar-sdk#6020 could close, and stellar/stellar-rpc#1037 would swap jhttp.NewBridge for the in-tree handler. Either way, three things the old bridge did need to carry over. I left them as separate comments: the shutdown drain and the concurrency cap on stellar/stellar-rpc#1037, and the ID escaping inline here. The prototype is in this gist, if it's useful as a starting point.

Comment thread serve.go
}
head := []byte(`{"jsonrpc":"2.0"`)
if r.id != "" {
head = append(append(head, `,"id":`...), r.id...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the echoed ID keep its HTML escaping? It's the one client-supplied value written back into the response. The old bridge escaped it as a side effect of re-encoding, so today "id":"<b>" comes back as "\u003cb\u003e". Keeping it costs a scan of the ID and keeps responses byte-identical to the old bridge:

func escapeID(id string) string {
	if !strings.ContainsAny(id, "<>&\u2028\u2029") {
		return id
	}
	var buf bytes.Buffer
	json.HTMLEscape(&buf, []byte(id))
	return buf.String()
}

In the bridge that's rsp.SetID(escapeID(rsp.ID())) before encoding. An in-tree handler would call it where it writes the ID.

@tamirms

tamirms commented Oct 8, 2026

Copy link
Copy Markdown

Upstreaming the fix is harder than it looks. jrpc2's Server is built around a message channel, and the Bridge serves HTTP by running a private client and server over an in-memory one, which is where the re-encoding comes from. Removing that round trip takes a second, channel-free way to run handlers inside Server, which is what ServeRequests is. It can't live in jhttp alone, because the context keys that handlers read through InboundRequest and ServerFromContext aren't exported. That path also changes what every existing Bridge user sees:

Those are design calls for the maintainer. When per-request contexts in the Bridge came up in creachadair#99, he suggested plain HTTP handlers instead.

@cjonas9 cjonas9 closed this Oct 12, 2026
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.

4 participants