A collection of tools for migrating away from Lwt

This is being developped as part of the project to migrate Ocsigen to direct-style concurrency. See the relevant Discuss post: https://discuss.ocaml.org/t/ann-ocsigen-migrating-to-effect-based-concurrency/16327

The tools in the collection are:

Installation

Using Opam:

opam install .

Make sure to install the tools in the Opam switch used to build your project.

Documentation

Remove usages of lwt_ppx

Usage:

$ dune fmt # Make sure the project is formatted to avoid unrelated diffs
$ lwt-ppx-to-let-syntax .
$ dune fmt # Remove formatting changes created by the tool

This will recursively scan the current directory and modify all .ml files. Expressions using let%, match%lwt, if%lwt, [%lwt.finally ..], etc.. will be rewritten using the equivalent Lwt library functions.

For example, this expression:

let _ =
  match%lwt x with
  | A -> y
  | B -> z

is rewritten:

let _ =
  Lwt.bind x (function
    | A -> y
    | B -> z)

To make the new code more idiomatic and closer to the original code, let%lwt is rewritten as let*. This example:

let _ =
  let%lwt x = y in
  ..

is rewritten to:

open Lwt.Syntax

let _ =
  let* x = y in
  ..

To disable this behaviour, eg. if let* is unwanted or already being used for something else, use the --use-lwt-bind flag. For example, the previous example rewritten using lwt-ppx-to-let-syntax --use-lwt-bind file.ml is:

let _ =
  Lwt.bind x (fun y ->
    ..)
Known caveats

Find implicit forks

This tool warns about values bound to let _ or passed to ignore that do not have a type annotation. The type annotations help find ignored Lwt threads, which are otherwise a challenge to translate into direct-style concurrency.

Usage:

$ lwt-lint .

To fix the warnings, add type annotations on let _ and ignore expressions and wrap implicit forks with Lwt.async (fun () -> ...).

Migrate from Lwt_log to Logs

Usage:

$ dune fmt # Make sure the project is formatted to avoid unrelated diffs
$ dune build @ocaml-index # Build the index (required)
$ ciao-lwt to-logs --migrate .
$ dune fmt # Remove formatting changes created by the tool

This will rewrite files containing occurrences of Lwt_log and Lwt_log_js. It must be run from the directory containing Dune's _build.

An example of use can be found here: https://github.com/ocsigen/ocsigenserver/pull/256

Other changes
Known caveats

Migrate from Lwt to Eio

Usage:

$ dune fmt # Make sure the project is formatted to avoid unrelated diffs
$ dune build @ocaml-index # Build the index (required)
$ ciao-lwt to-eio --migrate .
$ dune fmt # Remove formatting changes created by the tool

This will rewrite any files containing occurrences of Lwt or other lwt modules. It must be run from the directory containing Dune's _build.

Usages of Lwt are rewritten to use Eio instead. The tool can be adapted to support other concurrency libraries, see Concurrency_backend.

This works on both the syntax and type levels:

Transformation to Direct-style

Translating code from Lwt to direct-style means transforming binds (Lwt.bind, let*, etc.) into simple let and removing uses of Lwt.return. Concurrency is assured by libraries like Eio, which no longer require the bind and return operations.

This code:

let _ =
  let* x = f 1 in
  let+ y = f 2 in
  Lwt.bind (f 3) (fun z ->
    Lwt.return (x + y + z))

is changed to:

let _ =
  let x = f 1 in
  let y = f 2 in
  let z = f 3 in
  x + y + z

Other expressions are also simplified, like Lwt.catch and Lwt.fail, Lwt_list.iter_s, binding operators, and more.

Concurrency

Concurrency must now be created by defining explicit fork points (using Eio.Fiber) but code written for Lwt doesn't define them. With Lwt, forks can happen everywhere and every _ Lwt.t value is a potential promise.

This is the part of the process that requires the most manual intervention to make the transition successful.

Forks

For example, this is a fork:

let _ =
  let a = operation_1 () in
  let* b = operation_2 () in
  let* a = a in
  Lwt.return (a + b)

operation_1 () and operation_2 () run concurrently but if we naively remove binds and returns we generate code where the two operations run sequentially:

let _ =
  let a = operation_1 () in
  let b = operation_2 () in
  let a = a in
  a + b

The correct transformation is:

let _ =
  let a, b = Eio.Fiber.pair operation_1 operation_2 in
  a + b

Unfortunately, the tool is not able to generate the correct code in this case.

Explicit forks are handled correctly, like Lwt.pick, Lwt.both and Lwt.async.

Promises

Every _ Lwt.t value is a promise but transforming all of them to a Eio.Promise.t would be extremely impractical and against the goal of doing direct-style concurrency. Instead, only _ Lwt.t values that are not directly bind to are considered promises. This includes _ Lwt.t values that are part of a bigger value (eg. in a tuple, record or hashtbl).

For example, this is a promise:

type t = { p : int Lwt.t }
let x = { p = operation_1 () }

The tool is not able to generate the right code:

open Eio.Std
type t = { p : int Promise.t }
let x = { p = operation_1 () }

You'll have to rely on the types to catch the missing fork. The correct code is:

open Eio.Std
type t = { p : (int, exn) result Promise.t }
let x = { p = Fiber.fork_promise ~sw (fun () -> operation_1 ()) }

This can be harder to debug when combined with implicit forks. For example, the tool will completely change the meaning of the function f without modifying its code:

(* before: start a concurrent thread and return a [int Lwt.t option]. *)
let f () = Some (operation_1 ())
(* after: wait for the operation to complete and return a [int option]. *)
let f () = Some (operation_1 ())
Other caveats

Contribution

Contributions are most welcome!


This project is created and maintained by Tarides