turbo-editors/turbo-corepublic Fork 0
v0.9.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

🛟 Updated. 28d5985 · on v0.9.0 · k33g · 16h ago
README.md · 33 lines · 2.9 KBmarkdown
Blame HistoryOpen raw

jsonrpc

A JSON-RPC 2.0 connection over a framed byte stream.

It holds everything the specification says and nothing about any particular use of it: requests and their answers, notifications, requests arriving the other way, and the error object a failure travels in.

Why it is its own package

It was lsp's, until a second protocol needed it. lsp speaks to a language server, acp speaks to a coding agent, and between them the only difference at this level is how one message is marked off from the next — a Content-Length header for one, a newline for the other. That difference is the Framer interface; everything above it is shared.

Layers

File What it is
jsonrpc.go The wire types, the error codes, and the Framer seam
conn.go The connection: concurrent calls matched to replies by id, notifications, inbound requests
request.go An inbound request, and the answer that may come later

NewConn takes an io.ReadWriteCloser, not a command. That is what lets a whole client be tested against a peer in the same process, over net.Pipe — real framing, real concurrency, real decoding, no subprocess and no timing to get lucky with.

Two things that are easy to get wrong

The two directions are separate id spaces. A peer numbers its requests from one, and so do we. An agent really does send id: 1 while a call of ours carrying the same id is in flight — that is ordinary behaviour, not a broken agent. Replies are matched only against the ids this connection sent; an inbound request is never looked up among them.

An answer may have to come later. Every question a language server asks can be answered from what the client already knows. An agent asking permission to run a command cannot be: the answer comes from a dialog somebody has to look at, and opening one belongs to the goroutine that draws. So RequestFunc is handed a *Request and returns nothing — it may reply now, or keep the Request and reply several turns of an event loop later, from another goroutine.

Reply is guarded by a sync.Once. A window closing tears down a pending permission that may already have been answered, and two responses carrying one id would desynchronise a peer that matches answers to requests by exactly that id.

Tests

go test ./jsonrpc/ drives a real connection over net.Pipe against a peer written by hand — deliberately not built on Conn, because a peer sharing the code under test could not catch that code writing a malformed frame.

The peer reads continuously into a buffered channel rather than on demand. net.Pipe is synchronous, so a write blocks until somebody reads, and an on-demand reader deadlocks against a Reply made from the test goroutine. It also never calls t.Fatalf: that is not allowed outside the test goroutine, and doing it there hangs the run instead of failing it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# jsonrpc

A JSON-RPC 2.0 connection over a framed byte stream.

It holds everything the specification says and nothing about any particular use of it: requests and their answers, notifications, requests arriving the other way, and the error object a failure travels in.

## Why it is its own package

It was `lsp`'s, until a second protocol needed it. [`lsp`](../lsp/) speaks to a language server, `acp` speaks to a coding agent, and between them the *only* difference at this level is how one message is marked off from the next — a `Content-Length` header for one, a newline for the other. That difference is the `Framer` interface; everything above it is shared.

## Layers

| File | What it is |
| --- | --- |
| `jsonrpc.go` | The wire types, the error codes, and the `Framer` seam |
| `conn.go` | The connection: concurrent calls matched to replies by id, notifications, inbound requests |
| `request.go` | An inbound request, and the answer that may come later |

`NewConn` takes an `io.ReadWriteCloser`, not a command. That is what lets a whole client be tested against a peer **in the same process**, over `net.Pipe` — real framing, real concurrency, real decoding, no subprocess and no timing to get lucky with.

## Two things that are easy to get wrong

**The two directions are separate id spaces.** A peer numbers *its* requests from one, and so do we. An agent really does send `id: 1` while a call of ours carrying the same id is in flight — that is ordinary behaviour, not a broken agent. Replies are matched only against the ids this connection sent; an inbound request is never looked up among them.

**An answer may have to come later.** Every question a language server asks can be answered from what the client already knows. An agent asking permission to run a command cannot be: the answer comes from a dialog somebody has to look at, and opening one belongs to the goroutine that draws. So `RequestFunc` is handed a `*Request` and returns nothing — it may reply now, or keep the `Request` and reply several turns of an event loop later, from another goroutine.

`Reply` is guarded by a `sync.Once`. A window closing tears down a pending permission that may already have been answered, and two responses carrying one id would desynchronise a peer that matches answers to requests by exactly that id.

## Tests

`go test ./jsonrpc/` drives a real connection over `net.Pipe` against a peer written by hand — deliberately *not* built on `Conn`, because a peer sharing the code under test could not catch that code writing a malformed frame.

The peer reads continuously into a buffered channel rather than on demand. `net.Pipe` is synchronous, so a write blocks until somebody reads, and an on-demand reader deadlocks against a `Reply` made from the test goroutine. It also never calls `t.Fatalf`: that is not allowed outside the test goroutine, and doing it there hangs the run instead of failing it.