| 🛟 Updated. 28d5985 k33g 17h ago | 1 | package jsonrpc_test |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "net" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "codeberg.org/turbo-editors/turbo-core/jsonrpc" |
| 16 | ) |
| 17 | |
| 18 | // newlineFraming marks messages off with a newline, which is what the Agent |
| 19 | // Client Protocol does. It is the simplest framing there is, so the tests are |
| 20 | // about the JSON-RPC layer rather than about parsing headers. |
| 21 | type newlineFraming struct{} |
| 22 | |
| 23 | func (newlineFraming) ReadMessage(r *bufio.Reader) ([]byte, error) { |
| 24 | line, err := r.ReadBytes('\n') |
| 25 | if err != nil { |
| 26 | return nil, err |
| 27 | } |
| 28 | return line, nil |
| 29 | } |
| 30 | |
| 31 | func (newlineFraming) WriteMessage(w io.Writer, body []byte) error { |
| 32 | _, err := fmt.Fprintf(w, "%s\n", body) |
| 33 | return err |
| 34 | } |
| 35 | |
| 36 | // frame is one message the peer read, or the reason it could not. |
| 37 | type frame struct { |
| 38 | msg map[string]any |
| 39 | err error |
| 40 | } |
| 41 | |
| 42 | // peer is the far end of a connection, driven from a test. |
| 43 | // |
| 44 | // It is deliberately not built on Conn: a peer sharing the code under test |
| 45 | // could not catch that code writing a malformed frame. |
| 46 | // |
| 47 | // It reads continuously into a buffered channel rather than on demand, because |
| 48 | // net.Pipe is synchronous — a write blocks until somebody reads. With on-demand |
| 49 | // reads, a Reply made from the test goroutine deadlocks against the read that |
| 50 | // was going to collect it. Reading continuously is also what a real client |
| 51 | // does. |
| 52 | type peer struct { |
| 53 | t *testing.T |
| 54 | stream net.Conn |
| 55 | frames chan frame |
| 56 | } |
| 57 | |
| 58 | // newPair returns a Conn and the raw peer at the other end of it. |
| 59 | func newPair(t *testing.T, onNotify jsonrpc.NotificationFunc, onRequest jsonrpc.RequestFunc) (*jsonrpc.Conn, *peer) { |
| 60 | t.Helper() |
| 61 | |
| 62 | ours, theirs := net.Pipe() |
| 63 | conn := jsonrpc.NewConn(ours, newlineFraming{}, onNotify, onRequest) |
| 64 | go func() { _ = conn.Run() }() |
| 65 | |
| 66 | p := &peer{t: t, stream: theirs, frames: make(chan frame, 32)} |
| 67 | go p.readLoop() |
| 68 | |
| 69 | t.Cleanup(func() { _ = conn.Close(); _ = theirs.Close() }) |
| 70 | return conn, p |
| 71 | } |
| 72 | |
| 73 | // readLoop decodes every line the connection sends until it ends. |
| 74 | // |
| 75 | // It never calls t.Fatalf: that is not allowed outside the test goroutine, and |
| 76 | // doing it here hangs the run instead of failing it. Failures travel back as |
| 77 | // values instead. |
| 78 | func (p *peer) readLoop() { |
| 79 | reader := bufio.NewReader(p.stream) |
| 80 | for { |
| 81 | line, err := reader.ReadBytes('\n') |
| 82 | if err != nil { |
| 83 | close(p.frames) |
| 84 | return |
| 85 | } |
| 86 | |
| 87 | var msg map[string]any |
| 88 | if err := json.Unmarshal(line, &msg); err != nil { |
| 89 | p.frames <- frame{err: fmt.Errorf("the connection wrote %q, which is not JSON: %w", line, err)} |
| 90 | continue |
| 91 | } |
| 92 | p.frames <- frame{msg: msg} |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // send writes one raw line to the connection under test. |
| 97 | func (p *peer) send(line string) { |
| 98 | p.t.Helper() |
| 99 | |
| 100 | _ = p.stream.SetWriteDeadline(time.Now().Add(2 * time.Second)) |
| 101 | if _, err := fmt.Fprintf(p.stream, "%s\n", line); err != nil { |
| 102 | p.t.Fatalf("writing to the connection: %v", err) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // read returns the next message the connection sent, failing the test if none |
| 107 | // arrives. |
| 108 | func (p *peer) read() map[string]any { |
| 109 | p.t.Helper() |
| 110 | |
| 111 | select { |
| 112 | case got, open := <-p.frames: |
| 113 | if !open { |
| 114 | p.t.Fatal("the connection ended without sending anything more") |
| 115 | } |
| 116 | if got.err != nil { |
| 117 | p.t.Fatal(got.err) |
| 118 | } |
| 119 | return got.msg |
| 120 | case <-time.After(2 * time.Second): |
| 121 | p.t.Fatal("the connection sent nothing") |
| 122 | return nil |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // readsNothing fails the test if the connection sends anything within a short |
| 127 | // window. It is how "this must not be answered" is stated. |
| 128 | func (p *peer) readsNothing(what string) { |
| 129 | p.t.Helper() |
| 130 | |
| 131 | select { |
| 132 | case got := <-p.frames: |
| 133 | p.t.Fatalf("%s: the connection sent %v", what, got.msg) |
| 134 | case <-time.After(150 * time.Millisecond): |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestACallGetsItsAnswer(t *testing.T) { |
| 139 | conn, peer := newPair(t, nil, nil) |
| 140 | |
| 141 | answered := make(chan error, 1) |
| 142 | var result struct{ Name string } |
| 143 | go func() { answered <- conn.Call(t.Context(), "greet", map[string]string{"who": "world"}, &result) }() |
| 144 | |
| 145 | request := peer.read() |
| 146 | if request["method"] != "greet" { |
| 147 | t.Fatalf("the connection sent %v, want a greet request", request) |
| 148 | } |
| 149 | if request["jsonrpc"] != jsonrpc.Version { |
| 150 | t.Errorf("the request says jsonrpc %v, want %q", request["jsonrpc"], jsonrpc.Version) |
| 151 | } |
| 152 | peer.send(fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":{"Name":"hello"}}`, request["id"])) |
| 153 | |
| 154 | if err := <-answered; err != nil { |
| 155 | t.Fatalf("Call() error = %v", err) |
| 156 | } |
| 157 | if result.Name != "hello" { |
| 158 | t.Errorf("the result is %q, want %q", result.Name, "hello") |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | func TestAPeersRequestIdDoesNotStealAnAnswerMeantForOurOwnCall(t *testing.T) { |
| 163 | // Both sides number their requests from one, and an agent really does send |
| 164 | // `id: 1` while a call of ours with the same id is in flight — that is what |
| 165 | // the Agent Client Protocol's agents do. The two directions are separate |
| 166 | // id spaces, and a connection that shared one map would hand the peer's |
| 167 | // *request* to the caller waiting for an answer, or answer the request |
| 168 | // with the caller's result. |
| 169 | asked := make(chan string, 1) |
| 170 | onRequest := func(req *jsonrpc.Request) { |
| 171 | asked <- req.Method |
| 172 | req.Reply(map[string]string{"outcome": "allowed"}, nil) |
| 173 | } |
| 174 | conn, peer := newPair(t, nil, onRequest) |
| 175 | |
| 176 | answered := make(chan error, 1) |
| 177 | var result struct{ Name string } |
| 178 | go func() { answered <- conn.Call(t.Context(), "ours", nil, &result) }() |
| 179 | |
| 180 | ourRequest := peer.read() |
| 181 | if fmt.Sprint(ourRequest["id"]) != "1" { |
| 182 | t.Fatalf("our first call took id %v, want 1 — the rest of this test assumes it", ourRequest["id"]) |
| 183 | } |
| 184 | |
| 185 | // The peer now asks a question carrying the same id as our call in flight. |
| 186 | peer.send(`{"jsonrpc":"2.0","id":1,"method":"theirs","params":{}}`) |
| 187 | |
| 188 | select { |
| 189 | case method := <-asked: |
| 190 | if method != "theirs" { |
| 191 | t.Errorf("the handler was given %q, want %q", method, "theirs") |
| 192 | } |
| 193 | case <-time.After(2 * time.Second): |
| 194 | t.Fatal("the peer's request never reached the handler") |
| 195 | } |
| 196 | |
| 197 | reply := peer.read() |
| 198 | if fmt.Sprint(reply["id"]) != "1" { |
| 199 | t.Errorf("the answer carries id %v, want the peer's own 1", reply["id"]) |
| 200 | } |
| 201 | if reply["result"] == nil { |
| 202 | t.Errorf("the peer's request was answered with %v, want a result", reply) |
| 203 | } |
| 204 | |
| 205 | // Our own call must still be waiting, and must still get its own answer. |
| 206 | select { |
| 207 | case err := <-answered: |
| 208 | t.Fatalf("our call finished early with %v; the peer's request stole its answer", err) |
| 209 | case <-time.After(50 * time.Millisecond): |
| 210 | } |
| 211 | |
| 212 | peer.send(`{"jsonrpc":"2.0","id":1,"result":{"Name":"ours"}}`) |
| 213 | if err := <-answered; err != nil { |
| 214 | t.Fatalf("Call() error = %v", err) |
| 215 | } |
| 216 | if result.Name != "ours" { |
| 217 | t.Errorf("our call got %q, want %q", result.Name, "ours") |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestARequestMayBeAnsweredLongAfterItArrived(t *testing.T) { |
| 222 | // This is the whole reason this package exists apart from lsp: a permission |
| 223 | // dialog is answered by a person, on another goroutine, several turns of an |
| 224 | // event loop later. A handler that had to return its answer could not wait. |
| 225 | held := make(chan *jsonrpc.Request, 1) |
| 226 | _, peer := newPair(t, nil, func(req *jsonrpc.Request) { held <- req }) |
| 227 | |
| 228 | peer.send(`{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{}}`) |
| 229 | |
| 230 | request := <-held |
| 231 | if request.Method != "session/request_permission" { |
| 232 | t.Fatalf("the handler was given %q", request.Method) |
| 233 | } |
| 234 | |
| 235 | // Nothing may be written until the answer is given. |
| 236 | peer.readsNothing("answered before the handler did") |
| 237 | |
| 238 | // From another goroutine, as the event loop's dialog really does. |
| 239 | go request.Reply(map[string]any{"outcome": map[string]string{"outcome": "selected"}}, nil) |
| 240 | |
| 241 | reply := peer.read() |
| 242 | if fmt.Sprint(reply["id"]) != "7" { |
| 243 | t.Errorf("the answer carries id %v, want 7", reply["id"]) |
| 244 | } |
| 245 | if reply["result"] == nil { |
| 246 | t.Errorf("the answer is %v, want a result", reply) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | func TestOnlyTheFirstReplyToARequestIsSent(t *testing.T) { |
| 251 | // A window closing tears down a pending permission that may already have |
| 252 | // been answered. Two responses carrying one id would desynchronise the |
| 253 | // peer, which matches answers to requests by exactly that id. |
| 254 | held := make(chan *jsonrpc.Request, 1) |
| 255 | _, peer := newPair(t, nil, func(req *jsonrpc.Request) { held <- req }) |
| 256 | |
| 257 | peer.send(`{"jsonrpc":"2.0","id":3,"method":"ask","params":{}}`) |
| 258 | request := <-held |
| 259 | |
| 260 | go request.Reply("first", nil) |
| 261 | |
| 262 | first := peer.read() |
| 263 | |
| 264 | // Only now, with the first answer known to be on the wire, can the later |
| 265 | // ones be tried: Reply holds the once until its write has finished. |
| 266 | request.Reply("second", nil) |
| 267 | request.Reply(nil, errors.New("third")) |
| 268 | if first["result"] != "first" { |
| 269 | t.Errorf("the answer is %v, want the first one", first) |
| 270 | } |
| 271 | |
| 272 | // A second frame must never arrive. |
| 273 | peer.readsNothing("a second answer was sent") |
| 274 | } |
| 275 | |
| 276 | func TestAnErrorFromAHandlerReachesThePeerAsAnErrorObject(t *testing.T) { |
| 277 | _, peer := newPair(t, nil, func(req *jsonrpc.Request) { |
| 278 | req.Reply(nil, &jsonrpc.ResponseError{Code: jsonrpc.CodeInvalidParams, Message: "no uri"}) |
| 279 | }) |
| 280 | |
| 281 | peer.send(`{"jsonrpc":"2.0","id":2,"method":"fs/read_text_file","params":{}}`) |
| 282 | |
| 283 | reply := peer.read() |
| 284 | failure, ok := reply["error"].(map[string]any) |
| 285 | if !ok { |
| 286 | t.Fatalf("the answer is %v, want an error object", reply) |
| 287 | } |
| 288 | if failure["code"] != float64(jsonrpc.CodeInvalidParams) { |
| 289 | t.Errorf("the code is %v, want %d", failure["code"], jsonrpc.CodeInvalidParams) |
| 290 | } |
| 291 | if failure["message"] != "no uri" { |
| 292 | t.Errorf("the message is %v, want %q", failure["message"], "no uri") |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | func TestAPlainErrorFromAHandlerBecomesAnInternalError(t *testing.T) { |
| 297 | _, peer := newPair(t, nil, func(req *jsonrpc.Request) { |
| 298 | req.Reply(nil, errors.New("the file is a directory")) |
| 299 | }) |
| 300 | |
| 301 | peer.send(`{"jsonrpc":"2.0","id":2,"method":"fs/read_text_file","params":{}}`) |
| 302 | |
| 303 | failure, ok := peer.read()["error"].(map[string]any) |
| 304 | if !ok { |
| 305 | t.Fatal("want an error object") |
| 306 | } |
| 307 | if failure["code"] != float64(jsonrpc.CodeInternalError) { |
| 308 | t.Errorf("the code is %v, want %d", failure["code"], jsonrpc.CodeInternalError) |
| 309 | } |
| 310 | if !strings.Contains(fmt.Sprint(failure["message"]), "the file is a directory") { |
| 311 | t.Errorf("the message is %v, want the handler's own words in it", failure["message"]) |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | func TestARequestWithNoHandlerIsAnsweredMethodNotFound(t *testing.T) { |
| 316 | // Silence would leave the peer waiting for ever. A peer that asks for |
| 317 | // something this client does not do has to be told so. |
| 318 | _, peer := newPair(t, nil, nil) |
| 319 | |
| 320 | peer.send(`{"jsonrpc":"2.0","id":4,"method":"terminal/create","params":{}}`) |
| 321 | |
| 322 | failure, ok := peer.read()["error"].(map[string]any) |
| 323 | if !ok { |
| 324 | t.Fatal("want an error object") |
| 325 | } |
| 326 | if failure["code"] != float64(jsonrpc.CodeMethodNotFound) { |
| 327 | t.Errorf("the code is %v, want %d", failure["code"], jsonrpc.CodeMethodNotFound) |
| 328 | } |
| 329 | if failure["message"] != "terminal/create" { |
| 330 | t.Errorf("the message is %v, want the method's name", failure["message"]) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | func TestANotificationReachesTheHandlerAndIsNeverAnswered(t *testing.T) { |
| 335 | // A notification is exactly a message with no id, and answering one would |
| 336 | // send a response the peer has nothing to match it to. |
| 337 | arrived := make(chan string, 1) |
| 338 | _, peer := newPair(t, func(method string, _ json.RawMessage) { arrived <- method }, nil) |
| 339 | |
| 340 | peer.send(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s"}}`) |
| 341 | |
| 342 | select { |
| 343 | case method := <-arrived: |
| 344 | if method != "session/update" { |
| 345 | t.Errorf("the handler was given %q", method) |
| 346 | } |
| 347 | case <-time.After(2 * time.Second): |
| 348 | t.Fatal("the notification never arrived") |
| 349 | } |
| 350 | |
| 351 | peer.readsNothing("the notification was answered") |
| 352 | } |
| 353 | |
| 354 | func TestNotifySendsNoId(t *testing.T) { |
| 355 | conn, peer := newPair(t, nil, nil) |
| 356 | |
| 357 | go func() { _ = conn.Notify("session/cancel", map[string]string{"sessionId": "s"}) }() |
| 358 | |
| 359 | sent := peer.read() |
| 360 | if _, carries := sent["id"]; carries { |
| 361 | t.Errorf("the notification carries an id: %v", sent) |
| 362 | } |
| 363 | if sent["method"] != "session/cancel" { |
| 364 | t.Errorf("the notification is %v", sent) |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | func TestACallFailsWhenTheConnectionEnds(t *testing.T) { |
| 369 | // A peer that dies mid-request must not leave the caller blocked for ever. |
| 370 | conn, peer := newPair(t, nil, nil) |
| 371 | |
| 372 | answered := make(chan error, 1) |
| 373 | go func() { answered <- conn.Call(context.Background(), "greet", nil, nil) }() |
| 374 | |
| 375 | peer.read() // wait until the request is really on the wire |
| 376 | _ = peer.stream.Close() |
| 377 | |
| 378 | select { |
| 379 | case err := <-answered: |
| 380 | if !errors.Is(err, jsonrpc.ErrClosed) { |
| 381 | t.Errorf("Call() error = %v, want ErrClosed", err) |
| 382 | } |
| 383 | case <-time.After(2 * time.Second): |
| 384 | t.Fatal("the call never came back") |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | func TestACallEndsWithItsContext(t *testing.T) { |
| 389 | conn, peer := newPair(t, nil, nil) |
| 390 | |
| 391 | ctx, cancel := context.WithCancel(context.Background()) |
| 392 | answered := make(chan error, 1) |
| 393 | go func() { answered <- conn.Call(ctx, "slow", nil, nil) }() |
| 394 | |
| 395 | peer.read() |
| 396 | cancel() |
| 397 | |
| 398 | select { |
| 399 | case err := <-answered: |
| 400 | if !errors.Is(err, context.Canceled) { |
| 401 | t.Errorf("Call() error = %v, want context.Canceled", err) |
| 402 | } |
| 403 | case <-time.After(2 * time.Second): |
| 404 | t.Fatal("the call ignored its context") |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | func TestAResponseToAnIdWeNeverSentIsIgnored(t *testing.T) { |
| 409 | // The connection must not panic or die on one; an agent replaying a |
| 410 | // recorded session can easily send one. |
| 411 | conn, peer := newPair(t, nil, nil) |
| 412 | |
| 413 | peer.send(`{"jsonrpc":"2.0","id":999,"result":{}}`) |
| 414 | |
| 415 | // Still usable afterwards, which is the whole point. |
| 416 | answered := make(chan error, 1) |
| 417 | go func() { answered <- conn.Call(t.Context(), "greet", nil, nil) }() |
| 418 | request := peer.read() |
| 419 | peer.send(fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":null}`, request["id"])) |
| 420 | |
| 421 | if err := <-answered; err != nil { |
| 422 | t.Errorf("the connection stopped working after a stray response: %v", err) |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | func TestCallingOnAClosedConnectionFails(t *testing.T) { |
| 427 | conn, _ := newPair(t, nil, nil) |
| 428 | |
| 429 | if err := conn.Close(); err != nil { |
| 430 | t.Fatalf("Close() error = %v", err) |
| 431 | } |
| 432 | if err := conn.Call(t.Context(), "greet", nil, nil); !errors.Is(err, jsonrpc.ErrClosed) { |
| 433 | t.Errorf("Call() error = %v, want ErrClosed", err) |
| 434 | } |
| 435 | } |