OCaml 5 Effects Design
Core Principle
Effects for control flow, exceptions for errors.
| Concern |
Mechanism |
Example |
| Suspension (wait for data) |
Effects |
perform Block, perform Yield |
| Error (EOF, malformed) |
Exceptions |
raise End_of_file, Invalid_argument |
Layered Design
Effects should be handled at the source level, not in protocol parsers:
Application
↓
Protocol parser (Binary.Reader, Cbor, etc.)
↓ raises exceptions on EOF/error
bytesrw (effect-agnostic)
↓ just calls pull function
Source (Eio flow, affect fd, Unix fd)
↓ performs effects for suspension
Effect handler (Eio scheduler, affect runtime)
Why This Matters
- Parsers stay pure: No effect dependencies, easy to test
- Sources control blocking: Handler decides wait vs fail vs timeout
- Composability: Same parser works with any effect system
Effect Libraries
Eio
Effects are internal to the scheduler. User code looks synchronous:
(* Reading blocks via internal effects *)
let data = Eio.Flow.read flow buf
affect
Explicit effects for fiber scheduling:
type _ Effect.t +=
| Block : 'a block -> 'a Effect.t (* suspension *)
| Await : await -> unit Effect.t (* wait on fibers *)
| Yield : unit Effect.t (* cooperative yield *)
(* Block has callbacks for scheduler integration *)
type 'a block = {
block : handle -> unit; (* register blocked fiber *)
cancel : handle -> bool; (* handle cancellation *)
return : handle -> 'a (* extract result *)
}
bytesrw
Effect-agnostic streaming. The pull function you provide can perform any effects:
(* bytesrw just calls your function *)
let reader = Bytesrw.Bytes.Reader.make my_pull_fn
(* If my_pull_fn performs Eio effects, they propagate *)
(* If my_pull_fn performs affect Block, they propagate *)
(* bytesrw doesn't care - it just calls the function *)
Integration Pattern
Wire effect-performing sources to effect-agnostic libraries:
(* With Eio *)
let reader = Bytesrw_eio.bytes_reader_of_flow flow in
let r = Binary.Reader.of_reader reader in
parse r (* Eio effects happen in pull function *)
(* With affect *)
let pull () =
let buf = Bytes.create 4096 in
perform (Block { block; cancel; return = fun _ ->
Slice.make buf ~first:0 ~length:n })
in
let reader = Bytesrw.Bytes.Reader.make pull in
parse (Binary.Reader.of_reader reader)
When EOF Is Reached
Slice.eod from bytesrw means final EOF - no more data will ever come.
- Not "data not ready" (that's handled by effects in pull function)
- Not "try again later" (source already waited via effects)
- Parser should raise exception (EOF is an error condition)
Anti-Patterns
Don't: Define Await effect in protocol parsers
(* WRONG - parser shouldn't know about suspension *)
let get_byte t =
if no_data then perform Await; ...
Do: Let the source handle suspension
(* RIGHT - parser just reads, source handles waiting *)
let get_byte t =
match pull_next_slice t with (* may perform effects *)
| Some slice -> ...
| None -> raise End_of_file (* true EOF *)
References
Can you help me fix these diagnostics?
[ERROR] No config found for file test_eio.ml. Try calling 'dune build'. @test_eio.ml :L1:C1
[ERROR] No config found for file pass_simple. Try calling 'dune build'. @pass_simple :L1:C1
[ERROR] Unbound module Eio @test_eio.ml :L1:C6-C13
[ERROR] Unbound constructor T @pass_simple :L2:C1-C2
[ERROR] Illegal character (\000) @pass_simple :L2:C2-C3
[ERROR] Illegal character (\000) @pass_simple :L2:C3-C4
[ERROR] Illegal character (\000) @pass_simple :L2:C4-C5
[ERROR] Illegal character (\004) @pass_simple :L2:C6-C7
[ERROR] Illegal character (\000) @pass_simple :L2:C7-C8
[ERROR] Illegal character (\000) @pass_simple :L2:C8-C9
[ERROR] Illegal character (\000) @pass_simple :L2:C9-C10
[ERROR] Illegal character (\000) @pass_simple :L2:C10-C11
[ERROR] Illegal character (\000) @pass_simple :L2:C11-C12
[ERROR] Illegal character (\000) @pass_simple :L2:C12-C13
[ERROR] Syntax error @pass_simple :L2:C13-C14
[ERROR] This constant has type string but an expression was expected of type int @test_eio.ml :L5:C19-C25
[ERROR] Unbound module Eio @test_eio.ml :L6:C9-C22
[ERROR] Unbound module Eio_main
Hint: Did you mean Domain? @test_eio.ml :L11:C3-C15
[ERROR] The module Pass is an alias for module Dune__exe__Pass, which is missing @src/main.ml :L24:C14-C29
[ERROR] The module Tree is an alias for module Dune__exe__Tree, which is missing @src/main.ml :L25:C28-C46
[ERROR] The module Ui_tree is an alias for module Dune__exe__Ui_tree, which is missing @src/main.ml :L28:C19-C31
[ERROR] The module Ui_details is an alias for module Dune__exe__Ui_details, which is missing @src/main.ml :L31:C22-C37
[ERROR] Unbound constructor File @src/tree.ml :L33:C5-C9
[ERROR] This expression has type ui * (ui -> ui -> ui)
This is not a function; it cannot be applied. @src/main.ml :L35:C5-C14
[WARN] Error (warning 27): unused variable expanded. @src/tree.ml :L43:C24-C32
[WARN] Error (warning 27): unused variable depth. @src/tree.ml :L43:C33-C38
[WARN] Error (warning 27): unused variable acc. @src/tree.ml :L43:C39-C42
[ERROR] Unbound constructor File @src/tree.ml :L45:C7-C11
[ERROR] The value split_ui has type ui Lwd_utils.monoid = ui * (ui -> ui -> ui)
but an expression was expected of type ui @src/main.ml :L48:C7-C15
[ERROR] The value ui_with_keys has type ui but an expression was expected of type
ui Lwd.t @src/main.ml :L56:C29-C41
[WARN] Error (warning 26): unused variable root. @src/main.ml :L82:C7-C11
Called the Read tool with the following input: {"filePath":"/home/jm/data/code/toki/test_eio.ml"}
00001| open Eio.Std
00002|
00003| let test_fs env =
00004| let fs = env#fs in
00005| let path = fs / "/tmp" in
00006| match Eio.Path.kind ~follow:true path with
00007| | `Directory -> print_endline "Found /tmp directory"
00008| | _ -> print_endline "/tmp not found"
00009|
00010| let () =
00011| Eio_main.run @@ fun env ->
00012| test_fs env
00013|
(End of file - total 13 lines)
Called the Read tool with the following input: {"filePath":"/home/jm/data/code/toki/pass_simple"}
Read tool failed to read /home/jm/data/code/toki/pass_simple with the following error: Cannot read binary file: /home/jm/data/code/toki/pass_simple
Called the Read tool with the following input: {"filePath":"/home/jm/data/code/toki/src/main.ml"}
00001| (* Keyz - Terminal UI for GNU pass *)
00002|
00003| open Nottui
00004| (* * open Lwd *)
00005| (* open Lwd.Infix *)
00006| open Stdlib
00007|
00008| (* This version uses Eio fibers to handle:
00009| - Input events concurrently
00010| - Background tasks (like a timer updating the UI)
00011| - Responsive rendering
00012|
00013| This demonstrates how Eio's concurrency model integrates with
00014| Nottui's reactive UI system. *)
00015|
00016| (* Tree UI for password store *)
00017| let tree_ui fs =
00018|
00019| let ( / ) = Eio.Path.(/) in
00020| let path = fs / "/" / "home" / "jm" / ".password-store" in
00021| (* let files = Eio.Path.read_dir path in *)
00022| (* List.iter (fun s -> Printf.printf "%s" s) files; *)
00023|
00024| let tree = Pass.scan_store path in
00025| let state_var = Lwd.var (Tree.initial_state tree) in
00026|
00027| (* Tree pane on the left *)
00028| let tree_pane = Ui_tree.make state_var in
00029|
00030| (* Details pane on the right *)
00031| let details_pane = Ui_details.make state_var in
00032|
00033| (* Vertical split: tree left (40%), details right (60%) *)
00034| let split_ui =
00035| Ui.pack_x ~align:`Top [
00036| Ui.resize ~sw:4 ~sh:0 tree_pane; (* 40% width *)
00037| Ui.resize ~sw:6 ~sh:0 details_pane (* 60% width *)
00038| ]
00039| in
00040|
00041| (* Add global keyboard shortcuts *)
00042| let ui_with_keys =
00043| Ui.keyboard_area (fun key ->
00044| match key with
00045| | (`ASCII 'q', []) | (`ASCII 'Q', [`Ctrl]) ->
00046| `Handled (* quit handled at top level *)
00047| | _ -> `Unhandled
00048| ) split_ui
00049| in
00050|
00051| (* Add status bar *)
00052| let status_bar =
00053| Lwd.pure (Nottui_widgets.string "Keyz | hjkl: navigate | h/l: collapse/expand | q: quit")
00054| in
00055|
00056| Lwd_utils.pack Ui.pack_y [ui_with_keys; status_bar]
00057|
00058|
00059| let () =
00060|
00061|
00062| let quit = Lwd.var false in
00063| Eio_main.run @@ fun _env ->
00064| Eio.Switch.run @@ fun sw ->
00065|
00066|
00067| let fs = Eio.Stdenv.fs _env in
00068|
00069| let term = Notty_unix.Term.create () in
00070| let input_fd, _output_fd = Notty_unix.Term.fds term in
00071| let renderer = Renderer.make () in
00072|
00073| let ui =
00074| Lwd.map (tree_ui fs) ~f:(Ui.event_filter (function
00075| | `Key (`ASCII 'q', []) | `Key (`ASCII 'Q', [`Ctrl]) ->
00076| Lwd.set quit true;
00077| `Handled
00078| | _ -> `Unhandled
00079| ))
00080| in
00081|
00082| let root = Lwd.observe ui in
00083|
00084| (* Render the UI to the terminal *)
00085| (* let render () = *)
00086| (* let size = Notty_unix.Term.size term in *)
00087| (* let rec stabilize () = *)
00088| (* let tree = Lwd.quick_sample root in *)
00089| (* Renderer.update renderer size tree; *)
00090| (* let image = Renderer.image renderer in *)
00091| (* if Lwd.is_damaged root then stabilize () else image *)
00092| (* in *)
00093| (* let image = stabilize () in *)
00094| (* Notty_unix.Term.image term image *)
00095| (* in *)
00096|
00097| (* Input handling fiber *)
00098| let rec input_loop () =
00099| if not (Lwd.peek quit) then (
00100| try
00101| (* Check cancellation before blocking *)
00102| Eio.Fiber.check ();
00103|
00104| (* Wait for input using Eio *)
00105| Eio_unix.await_readable input_fd;
00106|
00107| (* Process the event *)
00108| match Notty_unix.Term.event term with
00109| | `End -> Lwd.set quit true
00110| | `Resize _ -> input_loop ()
00111| | #Notty.Unescape.event as event ->
00112| let event = (event : Notty.Unescape.event :> Ui.event) in
00113| ignore (Renderer.dispatch_event renderer event : [`Handled | `Unhandled]);
00114| input_loop ()
00115| with
00116| | Unix.Unix_error (err, fn, arg) ->
00117| (* Handle Unix errors (e.g., EBADF, EINTR) gracefully *)
00118| Eio.traceln "Input error: %s in %s(%s)"
00119| (Unix.error_message err) fn arg;
00120| Lwd.set quit true
00121| | End_of_file ->
00122| (* Terminal closed gracefully *)
00123| Lwd.set quit true
00124| | Eio.Cancel.Cancelled _ ->
00125| (* Graceful cancellation - don't re-raise *)
00126| ()
00127| )
00128| in
00129|
00130| (* Start input fiber *)
00131| Eio.Fiber.fork ~sw input_loop;
00132|
00133| (* Main render loop - just poll at 60 FPS *)
00134| (* let rec render_loop () = *)
00135| (* if not (Lwd.peek quit) then ( *)
00136| (* render (); *)
00137| (* (* Small sleep to avoid busy-waiting, still responsive *) *)
00138| (* Eio_unix.sleep (1.0 /. 60.0); *)
00139| (* render_loop () *)
00140| (* ) *)
00141| (* in *)
00142|
00143| (* render_loop (); *)
00144| (**)
00145| (* ignore (Lwd.quick_release root); *)
00146| (* Notty_unix.Term.release term *)
00147|
00148|
00149|
(End of file - total 149 lines)
Called the Read tool with the following input: {"filePath":"/home/jm/data/code/toki/src/tree.ml"}
00001| (* Tree data structure and navigation *)
00002|
00003| type entry = Pass.entry
00004| type tree = Pass.tree
00005|
00006| type state = {
00007| tree : tree;
00008| selected : string list; (* path to selected node *)
00009| expanded : string list list; (* set of expanded directory paths *)
00010| }
00011|
00012| let initial_state tree =
00013| let selected = [] in
00014| let expanded = [] in
00015| { tree; selected; expanded }
00016|
00017| let is_expanded state path =
00018| List.mem path state.expanded
00019|
00020| let toggle_expanded state path =
00021| if is_expanded state path then
00022| { state with expanded = List.filter (fun p -> p <> path) state.expanded }
00023| else
00024| { state with expanded = path :: state.expanded }
00025|
00026| let parent_path path =
00027| match List.rev path with
00028| | [] -> []
00029| | _ :: rev_tail -> List.rev rev_tail
00030|
00031| let rec flatten_tree tree : entry list =
00032| match tree with
00033| | File e -> [e]
00034| | Dir (e, children) ->
00035| e :: List.concat_map flatten_tree children
00036|
00037| let visible_entries state =
00038| (* TODO: filter based on expanded directories *)
00039| flatten_tree state.tree
00040|
00041| (* visible items in order with their paths and is_dir flag *)
00042| let visible_items state =
00043| let rec collect tree expanded depth acc =
00044| match tree with
00045| | File e ->
00046| (e.path, e, false, depth) :: acc
00047| | Dir (e, children) ->
00048| let path = e.path in
00049| let is_expanded = List.mem path expanded in
00050| let acc' = (path, e, true, depth) :: acc in
00051| if is_expanded then
00052| List.fold_left (fun a child -> collect child expanded (depth + 1) a) acc' children
00053| else
00054| acc'
00055| in
00056| List.rev (collect state.tree state.expanded 0 [])
00057|
00058| let move_selection state dir =
00059| let items = visible_items state in
00060| let find_index =
00061| List.find_index (fun (path, _, _, _) -> path = state.selected) items
00062| in
00063| match find_index with
00064| | None -> state (* keep selection *)
00065| | Some idx ->
00066| let new_idx =
00067| match dir with
00068| | `Up -> max 0 (idx - 1)
00069| | `Down -> min (List.length items - 1) (idx + 1)
00070| in
00071| match List.nth_opt items new_idx with
00072| | Some (path, _, _, _) -> { state with selected = path }
00073| | None -> state
00074|
(End of file - total 74 lines)