Skip to content

Commit e347593

Browse files
committed
refactor(guest-exec): CBOR wire protocol, drop hand-rolled framing
Replace length-prefixed JSON framing with self-delimiting CBOR in both directions. Request: client CBOR.encodes a map and sends raw bytes; agent reads one value via ciborium::from_reader (no EOF needed). Response: agent ciborium::into_writer's then closes; client reads to EOF and CBOR.decodes the accumulated buffer. stdout/stderr are CBOR byte strings (serde_bytes on Rust, %CBOR.Tag{tag: :bytes} unwrap on Elixir). Removes serde_json from the guest-agent crate. Cross-language contract is pinned by a Rust fixture (decodes the exact Elixir-produced anchor bytes) and a matching Elixir assertion on the same hex anchor.
1 parent b3b7a8a commit e347593

8 files changed

Lines changed: 169 additions & 159 deletions

File tree

lib/hyper/node/fire_vmm/exec.ex

Lines changed: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ defmodule Hyper.Node.FireVMM.Exec do
1313
→ "CONNECT 1024\\n"
1414
← "OK <port>\\n" (anything not starting with "OK " is an error)
1515
16-
Request frame: `<<byte_size(json)::32-unsigned-big>>` followed by UTF-8
17-
JSON `{argv, env?, cwd?, timeout_ms?}`. Keys with `nil` values are omitted.
16+
**Request**: a CBOR-encoded map `{argv, env?, cwd?, timeout_ms?}`. The
17+
client sends the bytes directly with no framing — CBOR is self-delimiting
18+
and the agent reads exactly one value via `ciborium::from_reader`.
1819
19-
Response frame: `<<exit_code::32-signed-big>>`, then two length-prefixed
20-
byte fields — `<<stdout_len::32>>` + stdout bytes, `<<stderr_len::32>>`
21-
+ stderr bytes.
20+
**Response**: a CBOR-encoded map `{exit_code, stdout, stderr}`. `stdout`
21+
and `stderr` are CBOR byte strings that unwrap from `%CBOR.Tag{tag: :bytes}`
22+
to raw binaries. The agent writes the value then closes the connection; the
23+
client reads to EOF and decodes the accumulated bytes.
2224
2325
## Readiness retry
2426
@@ -120,62 +122,52 @@ defmodule Hyper.Node.FireVMM.Exec do
120122

121123
@spec send_request(:gen_tcp.socket(), [String.t()], keyword()) :: :ok | {:error, term()}
122124
defp send_request(sock, argv, opts) do
123-
json = build_request_json(argv, opts)
124-
:gen_tcp.send(sock, [<<byte_size(json)::32>>, json])
125+
body =
126+
%{"argv" => argv}
127+
|> maybe_put("env", Keyword.get(opts, :env))
128+
|> maybe_put("cwd", Keyword.get(opts, :cwd))
129+
|> maybe_put("timeout_ms", Keyword.get(opts, :timeout_ms))
130+
|> CBOR.encode()
131+
132+
:gen_tcp.send(sock, body)
125133
end
126134

127-
@spec build_request_json([String.t()], keyword()) :: binary()
128-
defp build_request_json(argv, opts) do
129-
%{"argv" => argv}
130-
|> maybe_put("env", Keyword.get(opts, :env))
131-
|> maybe_put("cwd", Keyword.get(opts, :cwd))
132-
|> maybe_put("timeout_ms", Keyword.get(opts, :timeout_ms))
133-
|> Jason.encode!()
134-
end
135-
136-
@spec maybe_put(map(), String.t(), term()) :: map()
137-
defp maybe_put(map, _key, nil), do: map
138-
defp maybe_put(map, key, value), do: Map.put(map, key, value)
139-
140135
@spec recv_response(:gen_tcp.socket(), non_neg_integer()) ::
141136
{:ok, exec_result()} | {:error, term()}
142137
defp recv_response(sock, timeout_ms) do
143-
with {:ok, <<exit_code::32-signed>>} <- recv_exactly(sock, 4, timeout_ms),
144-
{:ok, <<stdout_len::32>>} <- recv_exactly(sock, 4, timeout_ms),
145-
{:ok, stdout} <- recv_exactly(sock, stdout_len, timeout_ms),
146-
{:ok, <<stderr_len::32>>} <- recv_exactly(sock, 4, timeout_ms),
147-
{:ok, stderr} <- recv_exactly(sock, stderr_len, timeout_ms) do
148-
{:ok, %{exit_code: exit_code, stdout: stdout, stderr: stderr}}
138+
with {:ok, buffer} <- recv_until_closed(sock, timeout_ms, <<>>) do
139+
case CBOR.decode(buffer) do
140+
{:ok, %{"exit_code" => code, "stdout" => out, "stderr" => err}, _rest} ->
141+
{:ok, %{exit_code: code, stdout: unwrap_bytes(out), stderr: unwrap_bytes(err)}}
142+
143+
{:ok, _other, _rest} ->
144+
{:error, {:cbor_decode, :unexpected_shape}}
145+
146+
{:error, reason} ->
147+
{:error, {:cbor_decode, reason}}
148+
end
149149
end
150150
end
151151

152-
# Read exactly `n` bytes, looping until all arrive. `:gen_tcp.recv/3` with a
153-
# positive length waits until that many bytes are buffered by the OTP TCP
154-
# driver, so the recursive branch is a defensive backstop rather than the
155-
# common path — but it makes the accumulation contract explicit and protects
156-
# against any future refactor to a lower-level read.
157-
@spec recv_exactly(:gen_tcp.socket(), non_neg_integer(), non_neg_integer()) ::
152+
@spec recv_until_closed(:gen_tcp.socket(), non_neg_integer(), binary()) ::
158153
{:ok, binary()} | {:error, term()}
159-
defp recv_exactly(_sock, 0, _timeout_ms), do: {:ok, <<>>}
160-
161-
defp recv_exactly(sock, n, timeout_ms) do
162-
case :gen_tcp.recv(sock, n, timeout_ms) do
163-
{:ok, data} when byte_size(data) == n ->
164-
{:ok, data}
165-
166-
{:ok, partial} ->
167-
case recv_exactly(sock, n - byte_size(partial), timeout_ms) do
168-
{:ok, rest} -> {:ok, partial <> rest}
169-
err -> err
170-
end
171-
172-
{:error, reason} ->
173-
{:error, reason}
154+
defp recv_until_closed(sock, timeout_ms, acc) do
155+
case :gen_tcp.recv(sock, 0, timeout_ms) do
156+
{:ok, data} -> recv_until_closed(sock, timeout_ms, acc <> data)
157+
{:error, :closed} -> {:ok, acc}
158+
{:error, reason} -> {:error, reason}
174159
end
175160
end
176161

177-
# Read bytes one at a time until `\n`; returns the line without the trailing
178-
# newline. Byte-by-byte is correct for the short (<20 byte) handshake line.
162+
@spec unwrap_bytes(term()) :: binary()
163+
defp unwrap_bytes(%CBOR.Tag{tag: :bytes, value: bin}), do: bin
164+
defp unwrap_bytes(nil), do: ""
165+
defp unwrap_bytes(bin) when is_binary(bin), do: bin
166+
167+
@spec maybe_put(map(), String.t(), term()) :: map()
168+
defp maybe_put(map, _key, nil), do: map
169+
defp maybe_put(map, key, value), do: Map.put(map, key, value)
170+
179171
@spec recv_line(:gen_tcp.socket(), non_neg_integer()) :: {:ok, String.t()} | {:error, term()}
180172
defp recv_line(sock, timeout_ms), do: recv_line(sock, timeout_ms, <<>>)
181173

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ defmodule Hyper.MixProject do
8484
{:grpc, "~> 1.0"},
8585
{:grpc_server, "~> 1.0"},
8686
{:horde, "~> 0.9"},
87+
{:cbor, "~> 1.0"},
8788
{:jason, "~> 1.4"},
8889
{:libcluster, "~> 3.3"},
8990
{:muontrap, "~> 1.5"},

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"acceptor_pool": {:hex, :acceptor_pool, "1.0.1", "d88c2e8a0be9216cf513fbcd3e5a4beb36bee3ff4168e85d6152c6f899359cdb", [:rebar3], [], "hexpm", "f172f3d74513e8edd445c257d596fc84dbdd56d2c6fa287434269648ae5a421e"},
33
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
44
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
5+
"cbor": {:hex, :cbor, "1.0.2", "9b0af85af291a556e10a0ffd48ba9a21a75e711828fafd3af193d56d95f0907f", [:mix], [], "hexpm", "edbc9b4a16eb93a582437b9b249c340a75af03958e338fb43d8c1be9fc65b864"},
56
"chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
67
"cowboy": {:hex, :cowboy, "2.16.1", "fa04080b602ff25c40a7700f2dc0152dbc1ba26b42093ae0fa9bb7a337d5a242", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "b8ea4dd317a043e3177ec840cfa3bcb47cfb41035d3abb24d954dc7d51def399"},
78
"cowlib": {:hex, :cowlib, "2.17.1", "3e6053016d1ab245730f0af688755476dcedb1c25ed8fb5751f59a2bfdc0c9af", [:make, :rebar3], [], "hexpm", "ff08bd17e6dd931445b18af77315b9b5fe052407110964ad2588c686b57b5e3f"},

native/guest-agent/Cargo.lock

Lines changed: 56 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

native/guest-agent/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ path = "src/main.rs"
1313

1414
[dependencies]
1515
serde = { version = "1", features = ["derive"] }
16-
serde_json = "1"
16+
ciborium = "0.2"
17+
serde_bytes = "0.11"
1718
nix = { version = "0.29", features = ["mount", "process", "socket", "signal", "fs"] }
1819

1920
[dev-dependencies]

native/guest-agent/src/wire.rs

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
//! Wire protocol between the Elixir guest-exec client and this agent.
2+
//!
3+
//! **Request** (Elixir client → agent): the client `CBOR.encode`s a map and
4+
//! sends the bytes directly — no length prefix, no delimiter. The agent reads
5+
//! exactly one self-delimiting CBOR value via `ciborium::from_reader`, which
6+
//! stops at the end of the definite-length map without needing EOF.
7+
//!
8+
//! **Response** (agent → client): the agent `ciborium::into_writer`s a
9+
//! [`Response`] map and then **closes the connection**. The client reads the
10+
//! socket to EOF, then calls `CBOR.decode` on the accumulated bytes.
11+
//!
12+
//! `stdout` and `stderr` are CBOR **byte strings** (major type 2), encoded via
13+
//! `serde_bytes` on the Rust side and unwrapped from `%CBOR.Tag{tag: :bytes}`
14+
//! on the Elixir side, so raw binary data round-trips without base64.
15+
116
use std::collections::BTreeMap;
217
use std::io::{self, Read, Write};
318

@@ -14,56 +29,29 @@ pub struct Request {
1429
pub timeout_ms: Option<u64>,
1530
}
1631

17-
#[derive(Debug, Clone, PartialEq)]
32+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1833
pub struct Response {
1934
pub exit_code: i32,
35+
#[serde(with = "serde_bytes")]
2036
pub stdout: Vec<u8>,
37+
#[serde(with = "serde_bytes")]
2138
pub stderr: Vec<u8>,
2239
}
2340

24-
fn read_u32(r: &mut impl Read) -> io::Result<u32> {
25-
let mut b = [0u8; 4];
26-
r.read_exact(&mut b)?;
27-
Ok(u32::from_be_bytes(b))
41+
pub fn read_request(r: impl Read) -> io::Result<Request> {
42+
ciborium::from_reader(r).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
2843
}
2944

30-
fn read_frame(r: &mut impl Read) -> io::Result<Vec<u8>> {
31-
let len = read_u32(r)? as usize;
32-
let mut buf = vec![0u8; len];
33-
r.read_exact(&mut buf)?;
34-
Ok(buf)
35-
}
36-
37-
fn write_frame(w: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
38-
w.write_all(&(bytes.len() as u32).to_be_bytes())?;
39-
w.write_all(bytes)
40-
}
41-
42-
pub fn read_request(r: &mut impl Read) -> io::Result<Request> {
43-
let frame = read_frame(r)?;
44-
serde_json::from_slice(&frame).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
45-
}
46-
47-
pub fn write_request(w: &mut impl Write, req: &Request) -> io::Result<()> {
48-
let json = serde_json::to_vec(req)?;
49-
write_frame(w, &json)
45+
pub fn write_request(mut w: impl Write, req: &Request) -> io::Result<()> {
46+
ciborium::into_writer(req, &mut w).map_err(|e| io::Error::other(e.to_string()))?;
47+
w.flush()
5048
}
5149

52-
pub fn write_response(w: &mut impl Write, resp: &Response) -> io::Result<()> {
53-
w.write_all(&resp.exit_code.to_be_bytes())?;
54-
write_frame(w, &resp.stdout)?;
55-
write_frame(w, &resp.stderr)?;
50+
pub fn write_response(mut w: impl Write, resp: &Response) -> io::Result<()> {
51+
ciborium::into_writer(resp, &mut w).map_err(|e| io::Error::other(e.to_string()))?;
5652
w.flush()
5753
}
5854

59-
pub fn read_response(r: &mut impl Read) -> io::Result<Response> {
60-
let mut code = [0u8; 4];
61-
r.read_exact(&mut code)?;
62-
let stdout = read_frame(r)?;
63-
let stderr = read_frame(r)?;
64-
Ok(Response {
65-
exit_code: i32::from_be_bytes(code),
66-
stdout,
67-
stderr,
68-
})
55+
pub fn read_response(r: impl Read) -> io::Result<Response> {
56+
ciborium::from_reader(r).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
6957
}

0 commit comments

Comments
 (0)