Table of contents

OCaml Backtraces on Uncaught Exceptions

Authors: Louis Gesbert
Date: 2024-04-25
Category: OCaml



A mystical Camel using its net to catch all uncaught... Butterflies.

A mystical Camel using its net to catch all uncaught... Butterflies.

Uncaught exception: Not_found

This blog post probably won't teach anything new to OCaml veterans; but for the others, you might be glad to learn that this very basic, yet surprisingly little-known feature of OCaml will give you backtraces with source file positions on any uncaught exception.

Since it can save hours of frustrating debugging, my intent is to give some publicity to this accidentally hidden feature.

PSA: define OCAMLRUNPARAM=b in your environment.

For those wanting to go further, I'll then go on with hints and guidelines for good exception management in OCaml.

For the details, everything here is documented in the Printexc module.

Get your stacktraces!

Compile-time errors are good, but sometimes you just have to cope with run-time failures.

Here is a simple (and buggy) program:

let dict = [
    "foo", "bar";
    "foo2", "bar2";
]

let rec replace = function
  | [] -> []
  | w :: words -> List.assoc w dict :: words

let () =
  let words = Array.to_list Sys.argv in
  List.iter print_endline (replace words)

Side note

For purposes of the example, we use List.assoc here; this relies on OCaml's structural equality, which is often a bad idea in projects, as it can break in surprising ways when the matched type gets more complex. A more serious implementation would use e.g. Map.Make with an explicit comparison function.

Here is the result of executing this program with no options:

$ ./foo
Fatal error: exception Not_found

This isn't very helpful, but no need for a debugger, lots of printf or tedious debugging, just do the following:

$ export OCAMLRUNPARAM=b
$ ./foo
Fatal error: exception Not_found
Raised at Stdlib__List.assoc in file "list.ml", line 191, characters 10-25
Called from Foo.replace in file "foo.ml", line 8, characters 18-35
Called from Foo in file "foo.ml", line 12, characters 26-41

Much more helpful! In most cases, this will be enough to find and fix the bug.

If you still don't get the backtrace, you may need to recompile with -g (with dune, ensure your default profile is dev or specify --profile=dev)

So, now we know where the failure occured... But not on what input. This is not a matter of backtraces: if that's an issue, define your own exceptions, with arguments, and raise that rather than the basic Not_found.

Hint

If you run the program directly from your editor, with a properly configured OCaml mode, the file positions in the backtrace should be parsed and become clickable, making navigation very quick and easy.

Improve your traces

The above works well in general, but depending on the complexity of the programs, there are some more advanced tricks that may be helpful, to preserve or improve the backtraces.

Properly Re-raising exceptions, and finalisers

It's pretty common to want a finaliser after some processing, here to remove a temporary file:

let with_temp_file basename (f: unit -> 'a) : 'a =
  let filename = Filename.temp_file basename in
  match f filename with
  | result ->
    Sys.remove filename;
    result
  | exception e ->
    Sys.remove filename;
    raise e

In simple cases this will work, but if e.g. you are using the Printf module before re-raising, it will break the printed backtrace.

  • Solution 1: use Fun.protect ~finally f that handles the backtrace properly.

  • Solution 2: manually, use raw backtrace access from the Printexc module:

    | exception e ->
      let bt = Printexc.get_raw_backtrace () in
      Sys.remove filename;
      Printexc.raise_with_backtrace e bt
    

Re-raising exceptions after catching them should always be done in this way.

There are holes in my backtrace!

Indeed, it may appear that not all function calls show up in the backtrace.

There are two main reasons for that:

  • functions can get inlined by the compiler, so they don't actually appear in the concrete backtrace at runtime;
  • tail-call optimisation also affects the stack, which can be visible here;

Don't run and disable all optimisations though! Some effort has been put in recording useful debugging information even in these cases. The Flambda pass of the compiler, which does more inlining, also actually makes it more traceable.

As a consequence, switching to Flambda will often give you more helpful backtraces with recursive functions and tail-calls. It can be done with opam install ocaml-option-flambda (this will recompile the whole opam switch).

Well, what if my program uses lwt?

Backtraces in this context are a complex matter -- but they can be simulated: a good practice is to use ppx_lwt and the let%lwt syntax rather than let* or Lwt.bind directly, because the ppx will insert calls that reconstruct "fake" backtrace information.

Guidelines for exception handling, and Control-C

Exceptions in OCaml can happen anywhere in the program: besides uses of raise, system errors can trigger them. In particular, if you want to implement clean termination on the user pressing Control-C without manually handling signals, you should call Sys.catch_break true ; you will then get a Sys.Break exception raised when the user interrupts the program.

Anyway, this is one reason why you must never use try .. with _ ->

let find_opt x m =
  try Some (Map.find x m)
  with _ -> None

The programmer was too lazy to write with Not_found. They may think this is OK since Map.find won't raise anything else. But if Control-C is pressed at the wrong time, this will catch it, and return None instead of stopping the program !

let find_debug x m =
  try Map.find x m
  with e ->
    let bt = Printexc.get_raw_backtrace () in
    Printf.eprintf "Error on %s!" (to_string x);
    Printexc.raise_with_backtrace e bt

This version is OK since it re-raises the exception. If you absolutely need to catch all exceptions, a last resort is to explicitely re-raise "uncatchable" exceptions:

let this_is_a_last_resort =
  try .. with
  | (Sys.Break | Assert_failure _ | Match_failure _) as e -> raise e
  | _ -> ..

In practice, you'll finally want to catch exceptions from your main function (cmdliner already offers to do this, for example); catching Sys.Break at that point will offer a better message than Uncaught exception, give you control over finalisation and the final exit code (the convention is to use 130 for Sys.Break).

Controlling the backtraces from OCaml

Setting OCAMLRUNPARAM=b in the environment works from the outside, but the module Printexc can also be used to enable or disable them from the OCaml program itself.

  • Printexc.record_backtrace: bool -> unit toggles the recording of backtraces. Forcing it off when running tests, or on when a debug flag is specified, can be good ideas;
  • Printexc.backtrace_status: unit -> bool checks if recording is enabled. This can be used when finalising the program to print the backtraces when enabled;

Nota Bene

The base library turns on backtraces recording by default. While I salute an attempt to remedy the issue that this post aims to address, this can lead to surprises when just linking the library can change the output of a program (e.g. this might require specific code for cram tests not to display backtraces)

The Printexc module also allows to register custom exception printers: if, following the advice above, you defined your own exceptions with parameters, use Printexc.register_printer to have that information available when they are uncaught.



About OCamlPro:

OCamlPro is a R&D lab founded in 2011, with the mission to help industrial users benefit from experts with a state-of-the-art knowledge of programming languages theory and practice.

  • We provide audit, support, custom developer tools and training for both the most modern languages, such as Rust, Wasm and OCaml, and for legacy languages, such as COBOL or even home-made domain-specific languages;
  • We design, create and implement software with great added-value for our clients. High complexity is not a problem for our PhD-level experts. For example, we helped the French Income Tax Administration re-adapt and improve their internally kept M language, we designed a DSL to model and express revenue streams in the Cinema Industry, codename Niagara, and we also developed the prototype of the Tezos proof-of-stake blockchain from 2014 to 2018.
  • We have a long history of creating open-source projects, such as the Opam package manager, the LearnOCaml web platform, and contributing to other ones, such as the Flambda optimizing compiler, or the GnuCOBOL compiler.
  • We are also experts of Formal Methods, developing tools such as our SMT Solver Alt-Ergo (check our Alt-Ergo Users' Club) and using them to prove safety or security properties of programs.

Please reach out, we'll be delighted to discuss your challenges: contact@ocamlpro.com or book a quick discussion.