editor/client/odin/daemon_client.odin
pavel 8b1723ce31 Replace hand-rolled JSON protocol parsing with real JSON
Daemon: parse requests with kotlinx.serialization and read fields from
the params object instead of scanning the whole line for key markers,
which picked up decoy keys ("path", "version", "id") occurring inside
buffer text. Build all responses and events with JsonObject builders,
which also fixes unescaped control characters in output and \uXXXX
escapes in input. Malformed lines now get a BAD_REQUEST reply instead
of corrupting field extraction.

Client: quote outgoing JSON strings with a strict json_quote helper
instead of Odin's %q verb, whose \x/\e escapes are not valid JSON.

Verified end to end: adversarial payloads (decoy protocol keys,
unicode, control chars, malformed lines) against the daemon, and the
full client smoke flow (ids 1-10) against a real Gradle workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:04:50 +02:00

646 lines
19 KiB
Odin

package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strconv"
import "core:strings"
import "core:sync"
import "core:thread"
import "core:time"
import json "core:encoding/json"
// Odin's %q verb emits \x/\e escapes that are not valid JSON, so outgoing
// strings are quoted here instead. Control characters become \u00XX.
json_quote :: proc(s: string, allocator := context.temp_allocator) -> string {
builder: strings.Builder
strings.builder_init(&builder, allocator)
strings.write_byte(&builder, '"')
for i := 0; i < len(s); i += 1 {
c := s[i]
switch c {
case '"':
strings.write_string(&builder, "\\\"")
case '\\':
strings.write_string(&builder, "\\\\")
case '\n':
strings.write_string(&builder, "\\n")
case '\r':
strings.write_string(&builder, "\\r")
case '\t':
strings.write_string(&builder, "\\t")
case:
if c < 0x20 {
fmt.sbprintf(&builder, "\\u%04x", c)
} else {
strings.write_byte(&builder, c)
}
}
}
strings.write_byte(&builder, '"')
return strings.to_string(builder)
}
Protocol_Message_Kind :: enum {
Invalid,
Response,
Event,
}
Protocol_Message :: struct {
kind: Protocol_Message_Kind,
id: int,
ok: bool,
event: string,
}
Daemon_Response :: struct {
id: int,
line: [dynamic]u8,
}
Daemon_Event :: struct {
line: [dynamic]u8,
}
Daemon_Process :: struct {
process: os.Process,
stdout: ^os.File,
running: bool,
}
Daemon_Client :: struct {
socket: net.TCP_Socket,
connected: bool,
next_id: int,
reader: ^thread.Thread,
event_mutex: sync.Mutex,
response_mutex: sync.Mutex,
diagnostic_events: [dynamic]Daemon_Event,
gradle_events: [dynamic]Daemon_Event,
pending_response_ids: [dynamic]int,
responses: [dynamic]Daemon_Response,
}
daemon_connect :: proc(port: int) -> Daemon_Client {
client := Daemon_Client{next_id = 1}
if port <= 0 do return client
socket, err := net.dial_tcp("127.0.0.1", port)
if err != nil {
fmt.println("daemon connect failed:", err)
return client
}
client.socket = socket
client.connected = true
return client
}
daemon_start_process :: proc(workspace: string) -> (Daemon_Process, int, bool) {
child, ok := daemon_start_process_begin(workspace)
if !ok {
return child, 0, false
}
port, port_ok := daemon_read_port(&child)
if !port_ok {
daemon_stop_process(&child)
return child, 0, false
}
return child, port, true
}
daemon_start_process_begin :: proc(workspace: string) -> (Daemon_Process, bool) {
child := Daemon_Process{}
stdout_r, stdout_w, pipe_err := os.pipe()
if pipe_err != nil {
fmt.println("daemon pipe failed:", pipe_err)
return child, false
}
command := []string{"gradle", "-p", workspace, "run", "--quiet"}
process, start_err := os.process_start(os.Process_Desc{
command = command,
stdout = stdout_w,
})
_ = os.close(stdout_w)
if start_err != nil {
fmt.println("daemon start failed:", start_err)
_ = os.close(stdout_r)
return child, false
}
child = Daemon_Process{process = process, stdout = stdout_r, running = true}
return child, true
}
daemon_stop_process :: proc(child: ^Daemon_Process) {
if child.stdout != nil {
_ = os.close(child.stdout)
child.stdout = nil
}
if child.running {
_ = os.process_terminate(child.process)
state, wait_err := os.process_wait(child.process, 2 * time.Second)
if wait_err != nil || !state.exited {
_ = os.process_kill(child.process)
_, _ = os.process_wait(child.process)
}
child.running = false
}
}
daemon_read_port :: proc(child: ^Daemon_Process) -> (int, bool) {
line: [dynamic]u8
defer delete(line)
start := time.now()
buf: [1024]u8
for time.diff(start, time.now()) < 30 * time.Second {
state, wait_err := os.process_wait(child.process, 0)
if wait_err == nil && state.exited {
fmt.println("daemon exited before announcing port")
return 0, false
}
has_data, data_err := os.pipe_has_data(child.stdout)
if data_err != nil {
fmt.println("daemon stdout failed:", data_err)
return 0, false
}
if !has_data {
time.sleep(100 * time.Millisecond)
continue
}
n, read_err := os.read(child.stdout, buf[:])
if read_err != nil {
fmt.println("daemon stdout read failed:", read_err)
return 0, false
}
for b in buf[:n] {
if b == '\n' {
port, ok := daemon_parse_port_line(string(line[:]))
if ok do return port, true
clear(&line)
} else {
append(&line, b)
}
}
}
fmt.println("daemon start timed out")
return 0, false
}
daemon_poll_port :: proc(child: ^Daemon_Process, line: ^[dynamic]u8) -> (int, bool, bool) {
if child == nil || !child.running || child.stdout == nil do return 0, false, true
state, wait_err := os.process_wait(child.process, 0)
if wait_err == nil && state.exited {
fmt.println("daemon exited before announcing port")
return 0, false, true
}
has_data, data_err := os.pipe_has_data(child.stdout)
if data_err != nil {
fmt.println("daemon stdout failed:", data_err)
return 0, false, true
}
if !has_data do return 0, false, false
buf: [1024]u8
n, read_err := os.read(child.stdout, buf[:])
if read_err != nil {
fmt.println("daemon stdout read failed:", read_err)
return 0, false, true
}
for b in buf[:n] {
if b == '\n' {
port, ok := daemon_parse_port_line(string(line[:]))
clear(line)
if ok do return port, true, false
} else {
append(line, b)
}
}
return 0, false, false
}
daemon_parse_port_line :: proc(line: string) -> (int, bool) {
if !strings.has_prefix(line, "PORT ") do return 0, false
port, ok := strconv.parse_int(line[len("PORT "):])
if !ok do return 0, false
return int(port), true
}
daemon_start_reader :: proc(client: ^Daemon_Client) {
if client.connected && client.reader == nil {
client.reader = thread.create_and_start_with_data(rawptr(client), daemon_reader_thread)
}
}
daemon_close :: proc(client: ^Daemon_Client) {
if client.connected {
net.close(client.socket)
client.connected = false
}
if client.reader != nil && thread.is_done(client.reader) {
thread.destroy(client.reader)
client.reader = nil
}
for &event in client.diagnostic_events {
delete(event.line)
}
delete(client.diagnostic_events)
client.diagnostic_events = nil
for &event in client.gradle_events {
delete(event.line)
}
delete(client.gradle_events)
client.gradle_events = nil
delete(client.pending_response_ids)
client.pending_response_ids = nil
for &response in client.responses {
delete(response.line)
}
delete(client.responses)
client.responses = nil
}
daemon_send :: proc(client: ^Daemon_Client, message: string) {
if !client.connected do return
bytes := transmute([]byte)message
_, err := net.send_tcp(client.socket, bytes)
if err != nil {
fmt.println("daemon send failed:", err)
daemon_close(client)
}
}
daemon_track_response :: proc(client: ^Daemon_Client, id: int) {
if id == 0 do return
if sync.mutex_guard(&client.response_mutex) {
append(&client.pending_response_ids, id)
}
}
daemon_send_workspace_open :: proc(client: ^Daemon_Client, workspace: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"workspace/open\",\"params\":{{\"root\":%s}}}}\n", id, json_quote(workspace))
daemon_send(client, request)
}
daemon_send_text_open :: proc(client: ^Daemon_Client, path: string, version: int, text: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/open\",\"params\":{{\"path\":%s,\"version\":%d,\"text\":%s}}}}\n", id, json_quote(path), version, json_quote(text))
daemon_send(client, request)
}
daemon_send_text_change :: proc(client: ^Daemon_Client, path: string, version: int, text: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/change\",\"params\":{{\"path\":%s,\"version\":%d,\"text\":%s}}}}\n", id, json_quote(path), version, json_quote(text))
daemon_send(client, request)
}
daemon_send_completion :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_hover :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_definition :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_references :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/references\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_rename :: proc(client: ^Daemon_Client, path: string, line, column: int, new_name: string) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d,\"newName\":%s}}}}\n", id, json_quote(path), line + 1, column + 1, json_quote(new_name))
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_gradle_tasks :: proc(client: ^Daemon_Client) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/tasks\",\"params\":{{}}}}\n", id)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_gradle_run :: proc(client: ^Daemon_Client, task: string) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/run\",\"params\":{{\"task\":%s}}}}\n", id, json_quote(task))
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_sync_active_buffer :: proc(client: ^Daemon_Client, editor: ^Editor, open: bool) {
active := editor_active_buffer(editor)
if active == nil do return
text := editor_active_text(editor)
defer delete(text)
if open {
daemon_send_text_open(client, active.path, active.buffer.version, string(text[:]))
} else {
daemon_send_text_change(client, active.path, active.buffer.version, string(text[:]))
}
}
daemon_reader_thread :: proc(data: rawptr) {
client := (^Daemon_Client)(data)
buf: [4096]u8
line: [dynamic]u8
defer delete(line)
for client.connected {
n, err := net.recv_tcp(client.socket, buf[:])
if err != nil || n == 0 {
client.connected = false
break
}
for b in buf[:n] {
if b == '\n' {
daemon_store_line(client, string(line[:]))
clear(&line)
} else {
append(&line, b)
}
}
}
}
daemon_store_line :: proc(client: ^Daemon_Client, line: string) {
message, value, ok := parse_protocol_message(line)
if !ok do return
defer json.destroy_value(value)
if message.kind == .Response {
if sync.mutex_guard(&client.response_mutex) {
tracked := false
for pending_id, index in client.pending_response_ids {
if pending_id == message.id {
ordered_remove(&client.pending_response_ids, index)
tracked = true
break
}
}
if !tracked do return
response := Daemon_Response{id = message.id}
for b in transmute([]u8)line {
append(&response.line, b)
}
append(&client.responses, response)
}
return
}
if message.kind != .Event do return
if message.event == "gradle/run" {
if sync.mutex_guard(&client.event_mutex) {
event := Daemon_Event{}
for b in transmute([]u8)line {
append(&event.line, b)
}
append(&client.gradle_events, event)
}
return
}
if message.event != "diagnostics/publish" do return
if sync.mutex_guard(&client.event_mutex) {
event := Daemon_Event{}
for b in transmute([]u8)line {
append(&event.line, b)
}
append(&client.diagnostic_events, event)
}
}
daemon_take_response :: proc(client: ^Daemon_Client, expected_id: int) -> [dynamic]u8 {
out: [dynamic]u8
if expected_id == 0 do return out
if sync.mutex_guard(&client.response_mutex) {
for &response, index in client.responses {
if response.id != expected_id do continue
for b in response.line {
append(&out, b)
}
delete(response.line)
ordered_remove(&client.responses, index)
break
}
}
return out
}
daemon_take_diagnostic_event :: proc(client: ^Daemon_Client) -> [dynamic]u8 {
out: [dynamic]u8
if !client.connected && len(client.diagnostic_events) == 0 do return out
if sync.mutex_guard(&client.event_mutex) {
if len(client.diagnostic_events) == 0 do return out
event := client.diagnostic_events[0]
for b in event.line {
append(&out, b)
}
delete(event.line)
ordered_remove(&client.diagnostic_events, 0)
}
return out
}
daemon_take_gradle_event :: proc(client: ^Daemon_Client) -> [dynamic]u8 {
out: [dynamic]u8
if !client.connected && len(client.gradle_events) == 0 do return out
if sync.mutex_guard(&client.event_mutex) {
if len(client.gradle_events) == 0 do return out
event := client.gradle_events[0]
for b in event.line {
append(&out, b)
}
delete(event.line)
ordered_remove(&client.gradle_events, 0)
}
return out
}
daemon_apply_latest_diagnostics :: proc(client: ^Daemon_Client, editor: ^Editor) {
for {
event := daemon_take_diagnostic_event(client)
if len(event) == 0 {
delete(event)
return
}
path, diagnostics := parse_diagnostics_event(string(event[:]))
if len(path) == 0 || !editor_set_diagnostics_for_path(editor, path, diagnostics[:]) {
diagnostics_destroy(diagnostics[:])
}
delete(path)
delete(diagnostics)
delete(event)
}
}
diagnostics_destroy :: proc(diagnostics: []Diagnostic) {
for diagnostic in diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
}
parse_diagnostics_event :: proc(event: string) -> (string, [dynamic]Diagnostic) {
diagnostics: [dynamic]Diagnostic
message, value, parsed := parse_protocol_message(event)
if !parsed do return "", diagnostics
defer json.destroy_value(value)
if message.kind != .Event || message.event != "diagnostics/publish" do return "", diagnostics
params, has_params := json_object_get(value, "params")
if !has_params do return "", diagnostics
path, _ := json_get_string(params, "path")
diagnostics_value, has_diagnostics := json_object_get(params, "diagnostics")
if !has_diagnostics do return strings.clone(path), diagnostics
#partial switch items in diagnostics_value {
case json.Array:
for item in items {
severity, _ := json_get_string(item, "severity")
diagnostic_message, _ := json_get_string(item, "message")
line, _ := json_get_int(item, "line")
column, _ := json_get_int(item, "column")
append(&diagnostics, Diagnostic{
line = max_int(line - 1, 0),
column = max_int(column - 1, 0),
severity = strings.clone(severity),
message = strings.clone(diagnostic_message),
})
}
}
return strings.clone(path), diagnostics
}
parse_protocol_message :: proc(line: string) -> (Protocol_Message, json.Value, bool) {
value, err := json.parse_string(line, .JSON, true)
if err != nil {
return Protocol_Message{}, value, false
}
message := Protocol_Message{}
if event, ok := json_get_string(value, "event"); ok {
message.kind = .Event
message.event = event
return message, value, true
}
if id, ok := json_get_int(value, "id"); ok {
message.kind = .Response
message.id = id
message.ok, _ = json_get_bool(value, "ok")
return message, value, true
}
return message, value, false
}
json_object_get :: proc(value: json.Value, key: string) -> (json.Value, bool) {
#partial switch object in value {
case json.Object:
if item, ok := object[key]; ok {
return item, true
}
}
return json.Value{}, false
}
json_get_string :: proc(value: json.Value, key: string) -> (string, bool) {
item, ok := json_object_get(value, key)
if !ok do return "", false
#partial switch s in item {
case json.String:
return string(s), true
}
return "", false
}
json_get_int :: proc(value: json.Value, key: string) -> (int, bool) {
item, ok := json_object_get(value, key)
if !ok do return 0, false
#partial switch n in item {
case json.Integer:
return int(n), true
case json.Float:
return int(n), true
}
return 0, false
}
json_get_bool :: proc(value: json.Value, key: string) -> (bool, bool) {
item, ok := json_object_get(value, key)
if !ok do return false, false
#partial switch b in item {
case json.Boolean:
return bool(b), true
}
return false, false
}