I was using eio to implement a proxy. The core logic is basically a two-way copy:
Fiber.both
(fun () -> Eio.Flow.copy client_flow server_flow)
(fun () -> Eio.Flow.copy server_flow client_flow)
I noticed that on Windows, the CPU usage is high when client keeps the connection open (typical behavior for a long-live TCP connection such as HTTP/1.1). I do not repro the same high CPU usage on Linux, so I suspect this is an issue with the Windows backend.
With the help of LLM, I was able to concentrate it into a MWE that demonstrates this behavior. This MWE spawns 3 fibers: a server that receives bytes and does not respond; a proxy that does the above; and a client that sends a few bytes and keeps the connection open. This program, on its own, uses about 100% single-core on Windows, whereas on Linux the CPU usage is negligible. If it is split into 3 individual programs, then only the process that runs the two-way copy uses 100% single-core.
MWE
open Eio.Std
let server_port = 1234
let proxy_port = 1235
(* --- 1. Mock Server ---
Just accepts connections and reads data until EOF. *)
let run_server net =
Eio.Switch.run @@ fun sw ->
let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, server_port) in
let socket = Eio.Net.listen ~sw net addr ~backlog:10 ~reuse_addr:true in
traceln "[Server] Listening on 127.0.0.1:%d" server_port;
Eio.Net.run_server socket ~on_error:(fun e -> traceln "[Server] Error: %a" Eio.Exn.pp e)
@@ fun flow _addr ->
traceln "[Server] Connection accepted.";
try
let buf = Cstruct.create 4096 in
while true do
let got = Eio.Flow.single_read flow buf in
traceln "[Server] Received %d bytes." got
done
with End_of_file -> traceln "[Server] Client disconnected."
(* --- 2. The Proxy (The component that triggers the bug) --- *)
let run_proxy net =
Eio.Switch.run @@ fun sw ->
let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, proxy_port) in
let socket = Eio.Net.listen ~sw net addr ~backlog:10 ~reuse_addr:true in
traceln "[Proxy] Listening on 127.0.0.1:%d, forwarding to %d" proxy_port server_port;
Eio.Net.run_server socket ~on_error:(fun e -> traceln "[Proxy] Error: %a" Eio.Exn.pp e)
@@ fun client_flow _addr ->
traceln "[Proxy] Connection received, connecting to server...";
try
Eio.Net.with_tcp_connect net ~host:"127.0.0.1" ~service:(string_of_int server_port) @@ fun server_flow ->
traceln "[Proxy] Connected to server. Starting bidirectional relay.";
(* Using Fiber.both and Eio.Flow.copy triggers the bug when connections
are kept open but stuck in specific read/write waiting states on Windows. *)
Fiber.both
(fun () ->
try Eio.Flow.copy client_flow server_flow
with e -> traceln "[Proxy] Client -> Server stopped: %a" Eio.Exn.pp e;
try Eio.Flow.shutdown server_flow `Send with _ -> ())
(fun () ->
try Eio.Flow.copy server_flow client_flow
with e -> traceln "[Proxy] Server -> Client stopped: %a" Eio.Exn.pp e;
try Eio.Flow.shutdown client_flow `Send with _ -> ())
with e ->
traceln "[Proxy] Session failed: %a" Eio.Exn.pp e
(* --- 3. Mock Client ---
Connects to the proxy, sends data, and then KEEPS THE CONNECTION OPEN. *)
let run_client net clock =
Eio.Switch.run @@ fun _sw ->
Eio.Time.sleep clock 0.5; (* Wait for server and proxy to start *)
traceln "[Client] Connecting to proxy at 127.0.0.1:%d..." proxy_port;
Eio.Net.with_tcp_connect net ~host:"127.0.0.1" ~service:(string_of_int proxy_port) @@ fun flow ->
traceln "[Client] Connected. Sending data...";
Eio.Flow.copy_string "Hello, keeping the connection open!" flow;
traceln "[Client] Data sent. Now keeping the connection open and waiting indefinitely...";
(* Block forever reading, simulating a client waiting for a response
or just holding a keep-alive connection open. *)
try
let buf = Cstruct.create 4096 in
while true do
let got = Eio.Flow.single_read flow buf in
traceln "[Client] Received %d bytes from server." got
done
with End_of_file -> traceln "[Client] Server closed connection."
| e -> traceln "[Client] Connection error: %a" Eio.Exn.pp e
(* --- Main --- *)
let () =
Eio_main.run @@ fun env ->
let net = Eio.Stdenv.net env in
let clock = Eio.Stdenv.clock env in
traceln "=========================================================";
traceln " Eio Windows High CPU MWE (Minimal Working Example)";
traceln "=========================================================";
traceln "This program runs a Server, a Proxy, and a Client concurrently.";
traceln "The client sends data and KEEPS the connection open.";
traceln "Observe the CPU usage. On Windows with eio_windows, the proxy";
traceln "shows 100%% core utilization.";
traceln "=========================================================";
Eio.Switch.run @@ fun sw ->
Fiber.fork ~sw (fun () -> run_server net);
Fiber.fork ~sw (fun () -> run_proxy net);
Fiber.fork ~sw (fun () -> run_client net clock);
(* Keep the program alive forever to observe CPU *)
Eio.Time.sleep clock Float.infinity
Environment:
# Name # Installed # Synopsis
eio 1.3 Effect-based direct-style IO API for OCaml
eio_main 1.3 Effect-based direct-style IO mainloop for OCaml
eio_windows 1.3 Eio implementation for Windows
ocaml 5.4.1 The OCaml compiler (virtual package)
I was using eio to implement a proxy. The core logic is basically a two-way copy:
I noticed that on Windows, the CPU usage is high when client keeps the connection open (typical behavior for a long-live TCP connection such as HTTP/1.1). I do not repro the same high CPU usage on Linux, so I suspect this is an issue with the Windows backend.
With the help of LLM, I was able to concentrate it into a MWE that demonstrates this behavior. This MWE spawns 3 fibers: a server that receives bytes and does not respond; a proxy that does the above; and a client that sends a few bytes and keeps the connection open. This program, on its own, uses about 100% single-core on Windows, whereas on Linux the CPU usage is negligible. If it is split into 3 individual programs, then only the process that runs the two-way copy uses 100% single-core.
MWE
Environment: