From 119284b9ee77eb9234ab2faa3c9f178109eb6dbb Mon Sep 17 00:00:00 2001 From: Hugo Heuzard Date: Fri, 29 May 2026 11:21:58 +0200 Subject: [PATCH] Runtime: only install the uncaughtException handler for standalone programs The runtime registered a process-wide "uncaughtException" handler at module-load time that called process.exit(2) on any uncaught exception. This emulates native OCaml's fatal-error behaviour, but it ran even when the generated code was loaded as a library: requiring jsoo output in the Node REPL would override the host's error handling and abort the whole process on errors unrelated to the OCaml code. Only install the handler when the program is run directly (require.main === module), not when loaded via require or in the REPL. The same guard is applied to the wasm_of_ocaml runtime. Fixes #1277 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGES.md | 5 +++++ compiler/tests-compiler/error.ml | 26 ++++++++++++++++++++++++++ runtime/js/sys.js | 17 +++++++++++++++++ runtime/wasm/runtime.js | 17 ++++++++++++++--- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0be3cfcee89..d5b96dc3b07 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -50,6 +50,11 @@ whole-program builds emit reversed `Filename.concat` operands and silently broke `Filename.temp_file` * Runtime/wasm: fix string conversion from JS to OCaml (#2230) +* Runtime: only install the process-wide "uncaughtException" handler when + the program is run directly (`node a.js`), not when it is loaded as a + library (`require("./a.js")`) or in the Node REPL, where it used to + override the host's error handling and abort the process on unrelated + errors (#1277) * Lib: fix several `Dom_html` bindings (#2221) * Lib: defer `Intl.{Collator,DateTimeFormat,...}` member lookups so the `Intl` module no longer throws at load time on hosts where diff --git a/compiler/tests-compiler/error.ml b/compiler/tests-compiler/error.ml index 80938f73a9d..905c09c5cc1 100644 --- a/compiler/tests-compiler/error.ml +++ b/compiler/tests-compiler/error.ml @@ -120,3 +120,29 @@ let _ = null 1 2 (try ignore (Str.search_forward (Str.regexp "TypeError: Cannot read") s 0) with Not_found -> print_endline s); [%expect {||}] + +let%expect_test "uncaught handler is not installed when loaded as a library (#1277)" = + (* When jsoo output is loaded with [require] (e.g. in the Node REPL or as a + library) rather than run directly, it must not register a process-wide + "uncaughtException" handler: doing so used to override the host's error + handling and abort the whole process on unrelated errors. *) + with_temp_dir ~f:(fun () -> + let _js_file = + {| let () = ignore (Sys.opaque_identity 0) |} + |> Filetype.ocaml_text_of_string + |> Filetype.write_ocaml ~name:"test.ml" + |> compile_ocaml_to_bc + |> compile_bc_to_javascript + in + let driver = + {| +require("./test.js"); +console.log( + "uncaughtException listeners: " + + process.listenerCount("uncaughtException")); +|} + |> Filetype.js_text_of_string + |> Filetype.write_js ~name:"driver.js" + in + print_string (run_javascript driver)); + [%expect {| uncaughtException listeners: 0 |}] diff --git a/runtime/js/sys.js b/runtime/js/sys.js index a8d600870b0..ed8171835d7 100644 --- a/runtime/js/sys.js +++ b/runtime/js/sys.js @@ -514,6 +514,18 @@ function caml_sys_const_standard_library_default(_unit) { function caml_setup_uncaught_exception_handler() { var process = globalThis.process; if (process?.on) { + // Only emulate OCaml's fatal-error behaviour (report the exception and + // exit with code 2) when the program is run directly, e.g. `node a.js`. + // When the generated code is loaded as a library (`require("./a.js")`) or + // in the Node REPL, registering a process-wide "uncaughtException" handler + // that calls process.exit would override the host's error handling and + // make unrelated errors abort the whole process. See ocsigen/js_of_ocaml#1277. + if ( + typeof module !== "undefined" && + typeof require !== "undefined" && + require.main !== module + ) + return; process.on("uncaughtException", function (err, origin) { caml_fatal_uncaught_exception(err); process.exit(2); @@ -525,5 +537,10 @@ function caml_setup_uncaught_exception_handler() { } }); } + // On QuickJS there is nothing to do: it exposes neither `process.on` nor + // `addEventListener`, so we install no global handler. The engine prints + // uncaught exceptions and exits itself, and loading the output as a library + // never overrides the host's error handling -- so no standalone guard like + // the one above (or `import.meta.main`) is needed here. } caml_setup_uncaught_exception_handler(); diff --git a/runtime/wasm/runtime.js b/runtime/wasm/runtime.js index 3b53ea566f5..c217fca644b 100644 --- a/runtime/wasm/runtime.js +++ b/runtime/wasm/runtime.js @@ -796,9 +796,20 @@ start_fiber = make_promising(caml_start_fiber); var _initialize = make_promising(_initialize); if (globalThis.process?.on) { - globalThis.process.on("uncaughtException", (err, _origin) => - caml_handle_uncaught_exception(err), - ); + // Only install the handler when run directly (`node a.js`), not when + // loaded as a library or in the REPL, where hijacking "uncaughtException" + // would override the host's error handling. See ocsigen/js_of_ocaml#1277. + if ( + !( + typeof module !== "undefined" && + typeof require !== "undefined" && + require.main !== module + ) + ) { + globalThis.process.on("uncaughtException", (err, _origin) => + caml_handle_uncaught_exception(err), + ); + } } else if (globalThis.addEventListener) { globalThis.addEventListener( "error",