Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## UNRELEASED

### Added

- Timeout support for `eval/3` via `:timeout` option (milliseconds). Long-running JavaScript code can be interrupted, returning `{:error, :timeout}`. Uses QuickJS's built-in interrupt handler mechanism with monotonic clock for accurate timing. Context remains usable after timeout.

## 0.1.0 - 2025-12-29

- Initial release of MquickjsEx
Expand Down
99 changes: 97 additions & 2 deletions c_src/mquickjs_ex.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <string.h>
#include <sys/time.h>
#include <sys/mman.h>
#include <time.h>

#include "erl_nif.h"
#include "vendor/mquickjs.h"
Expand Down Expand Up @@ -75,8 +76,23 @@ typedef struct {
uint8_t *mem_buf;
size_t mem_size;
JSContext *ctx;
uint64_t deadline_ms; /* Timeout deadline (0 = no timeout) */
} JsContext;

/* Get current time in milliseconds (monotonic clock) */
static uint64_t get_time_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}

/* Interrupt handler for timeout support */
static int timeout_interrupt_handler(JSContext *ctx, void *opaque) {
JsContext *js = (JsContext *)opaque;
if (js->deadline_ms == 0) return 0;
return (get_time_ms() > js->deadline_ms) ? 1 : 0;
}

static void js_log_func(void *opaque, const void *buf, size_t buf_len)
{
fwrite(buf, 1, buf_len, stdout);
Expand Down Expand Up @@ -638,6 +654,13 @@ static ERL_NIF_TERM nif_new(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
enif_make_atom(env, "js_context_failed"));
}

/* Initialize timeout to disabled */
js->deadline_ms = 0;

/* Set up context opaque for interrupt handler */
JS_SetContextOpaque(js->ctx, js);
JS_SetInterruptHandler(js->ctx, timeout_interrupt_handler);

/* Set up logging */
JS_SetLogFunc(js->ctx, js_log_func);

Expand All @@ -659,6 +682,21 @@ static ERL_NIF_TERM nif_eval(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]
return enif_make_badarg(env);
}

/* Get timeout parameter (0 = no timeout) */
unsigned long timeout_ms = 0;
if (argc > 2) {
if (!enif_get_ulong(env, argv[2], &timeout_ms)) {
return enif_make_badarg(env);
}
}

/* Set deadline for timeout */
if (timeout_ms > 0) {
js->deadline_ms = get_time_ms() + timeout_ms;
} else {
js->deadline_ms = 0;
}

/*
* IMPORTANT: Copy the code to a local buffer before passing to JS_Eval.
*
Expand All @@ -672,6 +710,7 @@ static ERL_NIF_TERM nif_eval(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]
*/
char *code_copy = malloc(code_bin.size + 1);
if (!code_copy) {
js->deadline_ms = 0; /* Reset deadline on error */
return enif_make_tuple2(env,
enif_make_atom(env, "error"),
enif_make_atom(env, "alloc_failed"));
Expand All @@ -683,14 +722,35 @@ static ERL_NIF_TERM nif_eval(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]
JSValue result = JS_Eval(js->ctx, code_copy, code_bin.size, "eval", JS_EVAL_RETVAL);
free(code_copy);

/* Reset deadline after eval */
uint64_t deadline_was = js->deadline_ms;
js->deadline_ms = 0;

if (JS_IsException(result)) {
/* Check if this was a timeout */
if (deadline_was > 0 && get_time_ms() > deadline_was) {
/* Clear the exception */
JS_GetException(js->ctx);
return enif_make_tuple2(env,
enif_make_atom(env, "error"),
enif_make_atom(env, "timeout"));
}

JSValue err = JS_GetException(js->ctx);

/* Try to get error message */
JSValue msg = JS_GetPropertyStr(js->ctx, err, "message");
if (JS_IsString(js->ctx, msg)) {
JSCStringBuf buf;
const char *str = JS_ToCString(js->ctx, msg, &buf);

/* Check for "interrupted" message which indicates timeout */
if (strcmp(str, "interrupted") == 0) {
return enif_make_tuple2(env,
enif_make_atom(env, "error"),
enif_make_atom(env, "timeout"));
}

size_t len = strlen(str);
ERL_NIF_TERM binary;
unsigned char *data = enif_make_new_binary(env, len, &binary);
Expand Down Expand Up @@ -945,6 +1005,21 @@ static ERL_NIF_TERM nif_run(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
return enif_make_badarg(env);
}

/* Get timeout parameter (0 = no timeout) - argv[3] */
unsigned long timeout_ms = 0;
if (argc > 3) {
if (!enif_get_ulong(env, argv[3], &timeout_ms)) {
return enif_make_badarg(env);
}
}

/* Set deadline for timeout */
if (timeout_ms > 0) {
js->deadline_ms = get_time_ms() + timeout_ms;
} else {
js->deadline_ms = 0;
}

/*
* Strategy: Use pure JavaScript for the yield mechanism.
*
Expand Down Expand Up @@ -1036,7 +1111,20 @@ static ERL_NIF_TERM nif_run(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
JSValue result = JS_Eval(js->ctx, full_code, written + code_bin.size, "run", JS_EVAL_RETVAL);
free(full_code);

/* Reset deadline after eval */
uint64_t deadline_was = js->deadline_ms;
js->deadline_ms = 0;

if (JS_IsException(result)) {
/* Check if this was a timeout */
if (deadline_was > 0 && get_time_ms() > deadline_was) {
/* Clear the exception */
JS_GetException(js->ctx);
return enif_make_tuple2(env,
enif_make_atom(env, "error"),
enif_make_atom(env, "timeout"));
}

JSValue err = JS_GetException(js->ctx);

/* Try to get error message */
Expand All @@ -1045,6 +1133,13 @@ static ERL_NIF_TERM nif_run(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
JSCStringBuf buf;
const char *msg = JS_ToCString(js->ctx, msg_val, &buf);

/* Check for "interrupted" message which indicates timeout */
if (strcmp(msg, "interrupted") == 0) {
return enif_make_tuple2(env,
enif_make_atom(env, "error"),
enif_make_atom(env, "timeout"));
}

/* Check if it's a yield exception */
const char *func_name;
size_t func_name_len;
Expand Down Expand Up @@ -1127,11 +1222,11 @@ static int load(ErlNifEnv *env, void **priv_data, ERL_NIF_TERM load_info)

static ErlNifFunc nif_funcs[] = {
{"nif_new", 1, nif_new, 0},
{"nif_eval", 2, nif_eval, 0},
{"nif_eval", 3, nif_eval, 0},
{"nif_get", 2, nif_get, 0},
{"nif_set_path", 3, nif_set_path, 0},
{"nif_gc", 1, nif_gc, 0},
{"nif_run", 3, nif_run, 0},
{"nif_run", 4, nif_run, 0},
};

ERL_NIF_INIT(Elixir.MquickjsEx.NIF, nif_funcs, load, NULL, NULL, NULL)
29 changes: 21 additions & 8 deletions lib/mquickjs_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ defmodule MquickjsEx do
If the context has registered callback functions (set via `set/3`), they
will be available to call from the JavaScript code.

## Options

* `:timeout` - Timeout in milliseconds. If the execution exceeds this duration,
it will be interrupted and `{:error, :timeout}` will be returned.
A value of `0` or omitting this option means no timeout (default).

## Examples

iex> {:ok, ctx} = MquickjsEx.new()
Expand All @@ -51,15 +57,22 @@ defmodule MquickjsEx do
iex> MquickjsEx.eval(ctx, ~s|"hello"|)
{:ok, "hello"}

# With timeout
iex> {:ok, ctx} = MquickjsEx.new()
iex> MquickjsEx.eval(ctx, "2 + 2", timeout: 1000)
{:ok, 4}

"""
def eval(%Context{} = ctx, code) when is_binary(code) do
def eval(%Context{} = ctx, code, opts \\ []) when is_binary(code) do
timeout = Keyword.get(opts, :timeout, 0)

if Context.has_callbacks?(ctx) do
case run_with_callbacks(ctx, code, ctx.callbacks) do
case run_with_callbacks(ctx, code, ctx.callbacks, timeout) do
{:ok, result, _ctx} -> {:ok, result}
{:error, _} = error -> error
end
else
NIF.nif_eval(ctx.ref, code)
NIF.nif_eval(ctx.ref, code, timeout)
end
end

Expand Down Expand Up @@ -384,9 +397,9 @@ defmodule MquickjsEx do
end

# Internal: run with callbacks (used by eval when callbacks present)
defp run_with_callbacks(%Context{} = ctx, code, callbacks) when is_map(callbacks) do
defp run_with_callbacks(%Context{} = ctx, code, callbacks, timeout) when is_map(callbacks) do
wrapped_code = wrap_with_callbacks(code, Map.keys(callbacks))
run_loop(ctx, wrapped_code, callbacks, [])
run_loop(ctx, wrapped_code, callbacks, [], timeout)
end

# Wrap user code with callback function definitions.
Expand Down Expand Up @@ -453,11 +466,11 @@ defmodule MquickjsEx do
end

# The run loop: execute code, handle yields, resume with results.
defp run_loop(%Context{} = ctx, code, callbacks, cached_results) do
defp run_loop(%Context{} = ctx, code, callbacks, cached_results, timeout) do
# Convert cached results to JSON strings for the NIF
cached_json = Enum.map(cached_results, &Jason.encode!/1)

case NIF.nif_run(ctx.ref, code, cached_json) do
case NIF.nif_run(ctx.ref, code, cached_json, timeout) do
{:ok, result} ->
{:ok, result, ctx}

Expand All @@ -468,7 +481,7 @@ defmodule MquickjsEx do
{:ok, callback_entry} ->
try do
{result, new_ctx} = execute_callback(callback_entry, args, ctx)
run_loop(new_ctx, code, callbacks, cached_results ++ [result])
run_loop(new_ctx, code, callbacks, cached_results ++ [result], timeout)
rescue
e ->
{:error, "Callback error in #{func_name}: #{Exception.message(e)}"}
Expand Down
4 changes: 2 additions & 2 deletions lib/mquickjs_ex/nif.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ defmodule MquickjsEx.NIF do
end

def nif_new(_mem_size), do: :erlang.nif_error(:not_loaded)
def nif_eval(_ctx, _code), do: :erlang.nif_error(:not_loaded)
def nif_eval(_ctx, _code, _timeout_ms), do: :erlang.nif_error(:not_loaded)
def nif_get(_ctx, _name), do: :erlang.nif_error(:not_loaded)
def nif_set_path(_ctx, _path, _value), do: :erlang.nif_error(:not_loaded)
def nif_gc(_ctx), do: :erlang.nif_error(:not_loaded)
def nif_run(_ctx, _code, _cached_results), do: :erlang.nif_error(:not_loaded)
def nif_run(_ctx, _code, _cached_results, _timeout_ms), do: :erlang.nif_error(:not_loaded)
end
13 changes: 13 additions & 0 deletions test/mquickjs_ex_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -692,4 +692,17 @@ defmodule MquickjsExTest do
assert {:ok, "hidden"} = MquickjsEx.get_private(ctx, :secret)
end
end

describe "timeout" do
test "interrupts infinite loop and context remains usable" do
{:ok, ctx} = MquickjsEx.new(memory: @default_memory)
assert {:error, :timeout} = MquickjsEx.eval(ctx, "while(true) {}", timeout: 100)
assert {:ok, 42} = MquickjsEx.eval(ctx, "42")
end

test "timeout 0 means no limit" do
{:ok, ctx} = MquickjsEx.new(memory: @default_memory)
assert {:ok, 4} = MquickjsEx.eval(ctx, "2 + 2", timeout: 0)
end
end
end