// mrubyAdapter.c -- mruby (Ruby) engine adapter for the broker. // // Each exposed native is a Kernel method bound to one shared trampoline. mrb_func_t carries no // userData, so the trampoline recovers the native's name from the current call's method id // (mrb_get_mid) and the owning context from mrb->ud, then marshals the Ruby arguments to // CalogValueT and dispatches through calogCall (honoring the actor route hook). Marshalling covers // scalars and binary-safe strings; aggregates cross both ways (Array<->list, Hash<->map). Functions // cross both ways too: a Ruby proc out becomes a refcounted CalogFnT pinned as a GC root // (mrb_gc_register) and dropped on release; a foreign CalogFnT in becomes a real Ruby Proc // (mrb_proc_new_cfunc_with_env) carrying the CalogFnT in its env -- the script calls it as f.call(x). // Foreign functions are tracked on the context and released at destroy (a cfunc proc's env has no // per-value finalizer). puts/print/p are provided (routed to stdout); there is no file/socket IO. // // Error model: on failure the trampoline raises an mruby exception (mrb_raise, which longjmps out // of the VM) after freeing any CalogValueT it owns. GC-arena save/restore brackets marshalling so // transient objects created here do not overflow the arena. #define _POSIX_C_SOURCE 200809L #include "mrubyAdapter.h" #include #include #include #include #include #include #include #include #include #include #include #include #define MRUBY_FOREIGN_INITIAL 8 struct CalogMrubyT { mrb_state *mrb; CalogT *broker; uint64_t ctxId; CalogFnT **foreignFns; // foreign function values pushed into this VM int32_t foreignCount; int32_t foreignCap; }; // Backs a CalogFnT exported from this VM: the owning context and the pinned Ruby callable // (a Proc/Method), kept GC-reachable by mrb_gc_register until the CalogFnT is released. typedef struct MrubyExportT { CalogMrubyT *context; mrb_value callable; } MrubyExportT; static int32_t mrubyCallableInvoke(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static void mrubyCallableRelease(CalogFnT *callable); static int32_t mrubyExportValue(CalogMrubyT *context, mrb_value callable, CalogFnT **out); static mrb_value mrubyForeignCall(mrb_state *mrb, mrb_value self); static int32_t mrubyFromValue(CalogMrubyT *context, const CalogValueT *value, mrb_value *out, int32_t depth); static int32_t mrubyInvokeArgs(CalogMrubyT *context, CalogFnT *callable, mrb_value *argv, mrb_int argc, mrb_value *out, char *errbuf, size_t errcap); static mrb_value mrubyMethodMissing(mrb_state *mrb, mrb_value self); static mrb_value mrubyP(mrb_state *mrb, mrb_value self); static mrb_value mrubyPrint(mrb_state *mrb, mrb_value self); static mrb_value mrubyPuts(mrb_state *mrb, mrb_value self); static int32_t mrubyToValue(CalogMrubyT *context, mrb_value value, CalogValueT *out, int32_t depth); static int32_t mrubyTrackForeign(CalogMrubyT *context, CalogFnT *callable); static mrb_value mrubyTrampoline(mrb_state *mrb, mrb_value self); int32_t calogMrubyCreate(CalogMrubyT **out, CalogT *broker, uint64_t ctxId) { CalogMrubyT *context; mrb_state *mrb; *out = NULL; context = (CalogMrubyT *)calloc(1, sizeof(*context)); if (context == NULL) { return calogErrOomE; } mrb = mrb_open(); if (mrb == NULL) { free(context); return calogErrOomE; } context->mrb = mrb; context->broker = broker; context->ctxId = ctxId; mrb->ud = context; // the trampoline recovers the context from here // Ruby ergonomics without pulling mruby-io: puts/print/p route to stdout. Defined on Kernel // so a bare `puts "x"` works at top level (calog scripts still use fs*/net* for real IO). mrb_define_method(mrb, mrb->kernel_module, "puts", mrubyPuts, MRB_ARGS_ANY()); mrb_define_method(mrb, mrb->kernel_module, "print", mrubyPrint, MRB_ARGS_ANY()); mrb_define_method(mrb, mrb->kernel_module, "p", mrubyP, MRB_ARGS_ANY()); // Bare-name export resolution: a top-level `exportedFn(args)` that is not a defined method // resolves to a broker export (see mrubyMethodMissing), like the hook engines. mrb_define_method(mrb, mrb->kernel_module, "method_missing", mrubyMethodMissing, MRB_ARGS_ANY()); *out = context; return calogOkE; } static int32_t mrubyCallableInvoke(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { MrubyExportT *export; CalogMrubyT *context; mrb_state *mrb; mrb_value *argv; mrb_value ret; int arena; int32_t index; int32_t status; export = (MrubyExportT *)userData; context = export->context; mrb = context->mrb; calogValueNil(result); argv = NULL; if (argCount > 0) { argv = (mrb_value *)calloc((size_t)argCount, sizeof(mrb_value)); if (argv == NULL) { return calogFail(result, calogErrOomE, "out of memory calling mruby callable"); } } arena = mrb_gc_arena_save(mrb); for (index = 0; index < argCount; index++) { status = mrubyFromValue(context, &args[index], &argv[index], 0); if (status != calogOkE) { mrb_gc_arena_restore(mrb, arena); free(argv); return calogFail(result, status, "failed to marshal argument into mruby"); } } ret = mrb_funcall_argv(mrb, export->callable, mrb_intern_lit(mrb, "call"), (mrb_int)argCount, argv); free(argv); if (mrb->exc != NULL) { mrb_value exc; mrb_value message; const char *text; exc = mrb_obj_value(mrb->exc); mrb->exc = NULL; message = mrb_funcall_argv(mrb, exc, mrb_intern_lit(mrb, "message"), 0, NULL); text = mrb_string_p(message) ? RSTRING_PTR(message) : "mruby callable raised"; status = calogFail(result, calogErrArgE, text); mrb_gc_arena_restore(mrb, arena); return status; } status = mrubyToValue(context, ret, result, 0); mrb_gc_arena_restore(mrb, arena); return status; } static void mrubyCallableRelease(CalogFnT *callable) { MrubyExportT *export; export = (MrubyExportT *)calogFnUserData(callable); mrb_gc_unregister(export->context->mrb, export->callable); free(export); } void calogMrubyDestroy(CalogMrubyT *context) { int32_t index; if (context == NULL) { return; } // Release the foreign function values pushed into this VM (see mrubyTrackForeign): a cfunc // proc's env has no per-value finalizer, so they are held until the context is destroyed. for (index = 0; index < context->foreignCount; index++) { calogFnRelease(context->foreignFns[index]); } free(context->foreignFns); if (context->mrb != NULL) { mrb_close(context->mrb); } free(context); } int32_t calogMrubyExport(CalogMrubyT *context, const char *name, CalogFnT **out) { mrb_state *mrb; mrb_value self; mrb_value method; mrb_value sym; int arena; int32_t status; mrb = context->mrb; *out = NULL; // Object#method returns a Method for a top-level `def name` (public or private). A Proc/lambda // held in a global ($name) also resolves through method_missing/global lookup below. self = mrb_top_self(mrb); sym = mrb_symbol_value(mrb_intern_cstr(mrb, name)); arena = mrb_gc_arena_save(mrb); method = mrb_funcall_argv(mrb, self, mrb_intern_lit(mrb, "method"), 1, &sym); if (mrb->exc != NULL) { mrb->exc = NULL; mrb_gc_arena_restore(mrb, arena); return calogErrNotFoundE; } status = mrubyExportValue(context, method, out); mrb_gc_arena_restore(mrb, arena); return status; } static int32_t mrubyExportValue(CalogMrubyT *context, mrb_value callable, CalogFnT **out) { mrb_state *mrb; MrubyExportT *export; int32_t status; mrb = context->mrb; *out = NULL; export = (MrubyExportT *)malloc(sizeof(*export)); if (export == NULL) { return calogErrOomE; } export->context = context; export->callable = callable; // Pin the callable as a GC root so it survives until the CalogFnT is released. mrb_gc_register(mrb, callable); status = calogFnCreate(out, context->broker, mrubyCallableInvoke, export, mrubyCallableRelease, context->ctxId); if (status != calogOkE) { mrb_gc_unregister(mrb, callable); free(export); return status; } return calogOkE; } int32_t calogMrubyExpose(CalogMrubyT *context, const char *name) { mrb_state *mrb; mrb = context->mrb; if (calogLookup(context->broker, name) == NULL) { return calogErrNotFoundE; } // A public Kernel method routed to the shared trampoline, so `name(args)` works at top level // in any context. The trampoline recovers `name` from the call's method id. mrb_define_method(mrb, mrb->kernel_module, name, mrubyTrampoline, MRB_ARGS_ANY()); return calogOkE; } // Call target for a foreign CalogFnT pushed into mruby as a Proc. The CalogFnT travels in the // proc's cfunc env (slot 0, a cptr); the context comes from mrb->ud. static mrb_value mrubyForeignCall(mrb_state *mrb, mrb_value self) { CalogMrubyT *context; CalogFnT *callable; mrb_value *argv; mrb_int argc; mrb_value out; int arena; int32_t status; char message[CALOG_ERR_MSG_CAP]; (void)self; context = (CalogMrubyT *)mrb->ud; callable = (CalogFnT *)mrb_cptr(mrb_proc_cfunc_env_get(mrb, 0)); mrb_get_args(mrb, "*", &argv, &argc); arena = mrb_gc_arena_save(mrb); status = mrubyInvokeArgs(context, callable, argv, argc, &out, message, sizeof(message)); mrb_gc_arena_restore(mrb, arena); if (status != calogOkE) { mrb_raise(mrb, E_RUNTIME_ERROR, message); } return out; } static int32_t mrubyFromValue(CalogMrubyT *context, const CalogValueT *value, mrb_value *out, int32_t depth) { mrb_state *mrb; mrb = context->mrb; *out = mrb_nil_value(); if (depth >= CALOG_MAX_DEPTH) { return calogErrDepthE; } switch (value->type) { case calogNilE: return calogOkE; case calogBoolE: *out = mrb_bool_value(value->as.b); return calogOkE; case calogIntE: *out = mrb_int_value(mrb, (mrb_int)value->as.i); return calogOkE; case calogRealE: *out = mrb_float_value(mrb, (mrb_float)value->as.r); return calogOkE; case calogStringE: *out = mrb_str_new(mrb, value->as.s.bytes, (mrb_int)value->as.s.length); return calogOkE; case calogAggE: { CalogAggT *aggregate; int64_t index; int32_t status; mrb_value child; aggregate = value->as.agg; if (calogAggIsKeyed(aggregate)) { mrb_value map; map = mrb_hash_new(mrb); for (index = 0; index < aggregate->arrayCount; index++) { status = mrubyFromValue(context, &aggregate->array[index], &child, depth + 1); if (status != calogOkE) { return status; } mrb_hash_set(mrb, map, mrb_int_value(mrb, (mrb_int)index), child); } for (index = 0; index < aggregate->pairCount; index++) { mrb_value key; status = mrubyFromValue(context, &aggregate->pairs[index].key, &key, depth + 1); if (status != calogOkE) { return status; } status = mrubyFromValue(context, &aggregate->pairs[index].value, &child, depth + 1); if (status != calogOkE) { return status; } mrb_hash_set(mrb, map, key, child); } *out = map; return calogOkE; } *out = mrb_ary_new_capa(mrb, (mrb_int)aggregate->arrayCount); for (index = 0; index < aggregate->arrayCount; index++) { status = mrubyFromValue(context, &aggregate->array[index], &child, depth + 1); if (status != calogOkE) { return status; } mrb_ary_push(mrb, *out, child); } return calogOkE; } case calogFnE: { // A foreign callable becomes a real Ruby Proc carrying the CalogFnT in its env; // the script calls it as f.call(x) / f.(x). Tracked on the context and released at // destroy (a cfunc proc's env has no per-value finalizer). mrb_value env[1]; if (mrubyTrackForeign(context, value->as.fn) != calogOkE) { return calogErrOomE; } env[0] = mrb_cptr_value(mrb, value->as.fn); *out = mrb_obj_value(mrb_proc_new_cfunc_with_env(mrb, mrubyForeignCall, 1, env)); calogFnRetain(value->as.fn); return calogOkE; } } return calogErrTypeE; } // Marshal argv[0..argc) into CalogValueT, invoke `callable`, and marshal the result into *out. // Non-raising: returns calogOkE (out set) or an error, copying a message into errbuf so the caller // can release its own references before raising (the mruby raise longjmps out). static int32_t mrubyInvokeArgs(CalogMrubyT *context, CalogFnT *callable, mrb_value *argv, mrb_int argc, mrb_value *out, char *errbuf, size_t errcap) { CalogValueT *args; CalogValueT result; mrb_int index; int32_t status; *out = mrb_nil_value(); args = NULL; if (argc > 0) { args = (CalogValueT *)calloc((size_t)argc, sizeof(CalogValueT)); if (args == NULL) { snprintf(errbuf, errcap, "out of memory marshalling arguments"); return calogErrOomE; } } for (index = 0; index < argc; index++) { status = mrubyToValue(context, argv[index], &args[index], 0); if (status != calogOkE) { mrb_int cleanup; for (cleanup = 0; cleanup < index; cleanup++) { calogValueFree(&args[cleanup]); } free(args); snprintf(errbuf, errcap, "failed to marshal an argument"); return status; } } status = calogFnInvoke(callable, args, (int32_t)argc, &result); for (index = 0; index < argc; index++) { calogValueFree(&args[index]); } free(args); if (status != calogOkE) { snprintf(errbuf, errcap, "%s", (result.type == calogStringE) ? result.as.s.bytes : "call failed"); calogValueFree(&result); return status; } status = mrubyFromValue(context, &result, out, 0); calogValueFree(&result); if (status != calogOkE) { snprintf(errbuf, errcap, "failed to marshal the result"); } return status; } // Kernel#method_missing: a bare name called at TOP LEVEL that is not a defined method may be a // broker export. Resolve and invoke it -- but only when self is the top-level main object, so a // missing method on any other receiver (obj.foo) stays a NoMethodError and is never hijacked. // This gives Ruby the bare-name export resolution the hook engines have (no VM change needed). static mrb_value mrubyMethodMissing(mrb_state *mrb, mrb_value self) { CalogMrubyT *context; mrb_value *argv; mrb_int argc; mrb_sym missing; const char *name; CalogValueT nameArg; CalogValueT resolved; mrb_value out; int arena; int32_t status; char message[CALOG_ERR_MSG_CAP]; context = (CalogMrubyT *)mrb->ud; mrb_get_args(mrb, "*", &argv, &argc); // argv[0] is the method name (a Symbol), rest are args if (argc < 1 || !mrb_symbol_p(argv[0])) { mrb_raise(mrb, E_NOMETHOD_ERROR, "method_missing without a method name"); } missing = mrb_symbol(argv[0]); if (!mrb_obj_equal(mrb, self, mrb_top_self(mrb))) { mrb_raisef(mrb, E_NOMETHOD_ERROR, "undefined method '%n'", missing); } name = mrb_sym_name(mrb, missing); if (calogValueString(&nameArg, name, (int64_t)strlen(name)) != calogOkE) { mrb_raisef(mrb, E_NOMETHOD_ERROR, "undefined method '%n'", missing); } calogValueNil(&resolved); calogCall(context->broker, "__calogExportResolve", &nameArg, 1, &resolved); calogValueFree(&nameArg); if (resolved.type != calogFnE) { calogValueFree(&resolved); mrb_raisef(mrb, E_NOMETHOD_ERROR, "undefined method '%n'", missing); } // It is an export: invoke with the remaining args. Keep our resolved reference across the // invoke (guards against a concurrent unexport), release it, THEN raise on failure -- so the // reference never leaks on the raise path. arena = mrb_gc_arena_save(mrb); status = mrubyInvokeArgs(context, resolved.as.fn, argv + 1, argc - 1, &out, message, sizeof(message)); calogValueFree(&resolved); mrb_gc_arena_restore(mrb, arena); if (status != calogOkE) { mrb_raise(mrb, E_RUNTIME_ERROR, message); } return out; } static mrb_value mrubyP(mrb_state *mrb, mrb_value self) { mrb_value *argv; mrb_int argc; mrb_int index; (void)self; mrb_get_args(mrb, "*", &argv, &argc); for (index = 0; index < argc; index++) { mrb_value text; text = mrb_inspect(mrb, argv[index]); fwrite(RSTRING_PTR(text), 1, (size_t)RSTRING_LEN(text), stdout); fputc('\n', stdout); } if (argc == 1) { return argv[0]; } return mrb_nil_value(); } static mrb_value mrubyPrint(mrb_state *mrb, mrb_value self) { mrb_value *argv; mrb_int argc; mrb_int index; (void)self; mrb_get_args(mrb, "*", &argv, &argc); for (index = 0; index < argc; index++) { mrb_value text; text = mrb_obj_as_string(mrb, argv[index]); fwrite(RSTRING_PTR(text), 1, (size_t)RSTRING_LEN(text), stdout); } return mrb_nil_value(); } static mrb_value mrubyPuts(mrb_state *mrb, mrb_value self) { mrb_value *argv; mrb_int argc; mrb_int index; (void)self; mrb_get_args(mrb, "*", &argv, &argc); if (argc == 0) { fputc('\n', stdout); return mrb_nil_value(); } for (index = 0; index < argc; index++) { mrb_value text; text = mrb_obj_as_string(mrb, argv[index]); fwrite(RSTRING_PTR(text), 1, (size_t)RSTRING_LEN(text), stdout); fputc('\n', stdout); } return mrb_nil_value(); } int32_t calogMrubyRun(CalogMrubyT *context, const char *source) { mrb_state *mrb; int arena; mrb = context->mrb; arena = mrb_gc_arena_save(mrb); mrb_load_string(mrb, source); if (mrb->exc != NULL) { mrb_value exc; mrb_value text; exc = mrb_obj_value(mrb->exc); mrb->exc = NULL; text = mrb_funcall_argv(mrb, exc, mrb_intern_lit(mrb, "inspect"), 0, NULL); fprintf(stderr, "mruby error: %s\n", mrb_string_p(text) ? RSTRING_PTR(text) : "(unknown)"); mrb_gc_arena_restore(mrb, arena); return calogErrArgE; } mrb_gc_arena_restore(mrb, arena); return calogOkE; } static int32_t mrubyToValue(CalogMrubyT *context, mrb_value value, CalogValueT *out, int32_t depth) { mrb_state *mrb; mrb = context->mrb; calogValueNil(out); if (depth >= CALOG_MAX_DEPTH) { return calogErrDepthE; } if (mrb_nil_p(value)) { return calogOkE; } switch (mrb_type(value)) { case MRB_TT_TRUE: calogValueBool(out, true); return calogOkE; case MRB_TT_FALSE: calogValueBool(out, false); return calogOkE; case MRB_TT_INTEGER: calogValueInt(out, (int64_t)mrb_integer(value)); return calogOkE; case MRB_TT_FLOAT: calogValueReal(out, (double)mrb_float(value)); return calogOkE; case MRB_TT_STRING: return calogValueString(out, RSTRING_PTR(value), (int64_t)RSTRING_LEN(value)); case MRB_TT_ARRAY: { CalogAggT *aggregate; int32_t status; mrb_int index; mrb_int count; status = calogAggCreate(&aggregate, calogListE); if (status != calogOkE) { return status; } count = RARRAY_LEN(value); for (index = 0; index < count; index++) { CalogValueT element; status = mrubyToValue(context, mrb_ary_ref(mrb, value, index), &element, depth + 1); if (status != calogOkE) { calogAggFree(aggregate); return status; } status = calogAggPush(aggregate, &element); if (status != calogOkE) { calogValueFree(&element); calogAggFree(aggregate); return status; } } calogValueAgg(out, aggregate); return calogOkE; } case MRB_TT_HASH: { CalogAggT *aggregate; mrb_value keys; int32_t status; mrb_int index; mrb_int count; status = calogAggCreate(&aggregate, calogMapE); if (status != calogOkE) { return status; } keys = mrb_hash_keys(mrb, value); count = RARRAY_LEN(keys); for (index = 0; index < count; index++) { mrb_value rubyKey; CalogValueT key; CalogValueT element; rubyKey = mrb_ary_ref(mrb, keys, index); status = mrubyToValue(context, rubyKey, &key, depth + 1); if (status != calogOkE) { calogAggFree(aggregate); return status; } status = mrubyToValue(context, mrb_hash_get(mrb, value, rubyKey), &element, depth + 1); if (status != calogOkE) { calogValueFree(&key); calogAggFree(aggregate); return status; } status = calogAggSet(aggregate, &key, &element); if (status != calogOkE) { calogValueFree(&key); calogValueFree(&element); calogAggFree(aggregate); return status; } } calogValueAgg(out, aggregate); return calogOkE; } case MRB_TT_PROC: { CalogFnT *callable; int32_t status; status = mrubyExportValue(context, value, &callable); if (status != calogOkE) { return status; } calogValueFn(out, callable); return calogOkE; } default: break; } // Other reference types (symbols, objects, ranges, ...) have no CalogValueT equivalent. return calogErrUnsupportedE; } // Record a foreign function pushed into this VM so it is released at destroy (a cfunc proc's env // has no per-value finalizer to release it when the proc is collected). static int32_t mrubyTrackForeign(CalogMrubyT *context, CalogFnT *callable) { if (context->foreignCount == context->foreignCap) { int32_t newCap; CalogFnT **resized; newCap = (context->foreignCap == 0) ? MRUBY_FOREIGN_INITIAL : context->foreignCap * CALOG_GROWTH_FACTOR; resized = (CalogFnT **)realloc(context->foreignFns, (size_t)newCap * sizeof(CalogFnT *)); if (resized == NULL) { return calogErrOomE; } context->foreignFns = resized; context->foreignCap = newCap; } context->foreignFns[context->foreignCount] = callable; context->foreignCount++; return calogOkE; } static mrb_value mrubyTrampoline(mrb_state *mrb, mrb_value self) { CalogMrubyT *context; const char *name; mrb_value *argv; mrb_int argc; CalogValueT *args; CalogValueT result; mrb_value out; int arena; mrb_int index; int32_t status; (void)self; context = (CalogMrubyT *)mrb->ud; name = mrb_sym_name(mrb, mrb_get_mid(mrb)); // which exposed native was called mrb_get_args(mrb, "*", &argv, &argc); args = NULL; if (argc > 0) { args = (CalogValueT *)calloc((size_t)argc, sizeof(CalogValueT)); if (args == NULL) { mrb_raise(mrb, E_RUNTIME_ERROR, "out of memory marshalling native arguments"); } } arena = mrb_gc_arena_save(mrb); for (index = 0; index < argc; index++) { status = mrubyToValue(context, argv[index], &args[index], 0); if (status != calogOkE) { mrb_int cleanup; for (cleanup = 0; cleanup < index; cleanup++) { calogValueFree(&args[cleanup]); } free(args); mrb_raise(mrb, E_TYPE_ERROR, "failed to marshal a native argument"); } } status = calogCall(context->broker, name, args, (int32_t)argc, &result); for (index = 0; index < argc; index++) { calogValueFree(&args[index]); } free(args); if (status != calogOkE) { char message[CALOG_ERR_MSG_CAP]; snprintf(message, sizeof(message), "%s", (result.type == calogStringE) ? result.as.s.bytes : "native call failed"); calogValueFree(&result); mrb_raise(mrb, E_RUNTIME_ERROR, message); } status = mrubyFromValue(context, &result, &out, 0); calogValueFree(&result); mrb_gc_arena_restore(mrb, arena); if (status != calogOkE) { mrb_raise(mrb, E_TYPE_ERROR, "failed to marshal the native result"); } return out; }