Skip to main content

c_src/midiio_nif.c

/*
 * midiio_nif.c — NIF over the minimidio C library.
 *
 * arc1/slice1: load callback + per-call context resource lifecycle.
 * arc1/slice3: read-only discovery — list_inputs/1, list_outputs/1, caps/1.
 * arc2/slice1: output device resource + lifecycle (open_output/close).
 * arc2/slice2: send/2 over the raw seam (midiio_send.h), the first dirty NIF.
 * No inbound / recv / enif_send / owner pid yet (arc 3).
 * See docs/planning/v0.1.0/ for the slice docs and ledgers.
 */

#define MINIMIDIO_IMPLEMENTATION
#include "minimidio.h"

#include <erl_nif.h>
#include <string.h>
#include <stdatomic.h>

/* The raw send + receive seams + interim adapters (arc2/slice2, arc3/slice1).
 * Included after minimidio.h because they use mm_device / mm_message. */
#include "midiio_send.h"
#include "midiio_recv.h"

/* Compile-time backend atom name, picked by minimidio's platform macro
 * (defined in minimidio.h before the public structs). */
#if defined(MM_BACKEND_COREMIDI)
#  define MIDIIO_BACKEND "coremidi"
#elif defined(MM_BACKEND_WINMM)
#  define MIDIIO_BACKEND "winmm"
#elif defined(MM_BACKEND_ALSA)
#  define MIDIIO_BACKEND "alsa"
#elif defined(MM_BACKEND_WEBMIDI)
#  define MIDIIO_BACKEND "webmidi"
#else
#  define MIDIIO_BACKEND "unknown"
#endif

/* ── Resource type ──────────────────────────────────────────────────────────
 * The context is embedded by value. `live` is the slice's own lifecycle flag:
 * mm_context_uninit guards on its internal `initialized`, but the resource must
 * track liveness independently so an explicit context_close/1 followed by the
 * GC-triggered destructor does not uninit twice. The `live` flag is the single
 * source of truth; do_uninit is the one path that flips it.
 */
typedef struct {
    mm_context ctx;
    int        live;
} midiio_ctx_res;

static ErlNifResourceType *g_ctx_res_type = NULL;

/* A device owns its own per-device mm_context (DESIGN §2 / arc-2 model): no
 * shared registry context — the context is embedded, so one guarded cleanup
 * closes the port then uninits the context.
 *
 * arc3/slice1 adds, for inputs and the F1 close:
 *  - is_input: outputs leave owner/keep inert;
 *  - owner: the recv target, read by the recv thread, written by set_owner/2;
 *  - kept: whether the recv-thread reference (enif_keep_resource) is still held
 *    (released exactly once, after mm_in_stop, by stop_input/close/cleanup);
 *  - lock: a PER-DEVICE mutex guarding owner R/W, send's live-check-and-use, and
 *    cleanup's teardown — so a concurrent send and close cannot UAF (finding F1).
 *    Uncontended under the single-owner contract (DESIGN §4 D3 realtime intent).
 *    Created in every open_*; destroyed LAST in the destructor (never while held). */
typedef struct {
    mm_context    ctx;
    mm_device     dev;
    int           live;
    int           is_input;
    int           kept;
    int           monitored;   /* an owner-death monitor is armed (inputs only) */
    ErlNifPid     owner;
    ErlNifMonitor monitor;     /* fires down_device when the owner process dies */
    ErlNifMutex  *lock;
} midiio_dev_res;

static ErlNifResourceType *g_dev_res_type = NULL;

/* uninit accounting: the destructor runs on a scheduler thread while an
 * explicit close runs on the caller thread, so the live-flag transition and
 * the count are guarded by one mutex (nif-thread-safety: shared mutable state
 * needs explicit synchronization). The count exists for test verification of
 * "exactly one uninit per context" (ledger row 7). */
static ErlNifMutex *g_uninit_lock  = NULL;
/* Atomic: context cleanup flips it under g_uninit_lock, but device cleanup now
 * runs under the *per-device* lock (F1), so the shared counter needs its own
 * lock-free synchronization rather than the global mutex. */
static atomic_int   g_uninit_count = 0;

/* Atoms pre-made in load() so they are valid in any environment
 * (erl-nif best practice). */
static ERL_NIF_TERM am_ok;
static ERL_NIF_TERM am_error;
static ERL_NIF_TERM am_invalid_arg;
static ERL_NIF_TERM am_no_backend;
static ERL_NIF_TERM am_out_of_range;
static ERL_NIF_TERM am_already_open;
static ERL_NIF_TERM am_not_open;
static ERL_NIF_TERM am_owner_not_alive; /* set_owner/2 handoff to a dead pid */
static ERL_NIF_TERM am_alloc_failed;

/* caps/1 map keys + boolean values + the compile-time backend atom (slice 3). */
static ERL_NIF_TERM am_true;
static ERL_NIF_TERM am_false;
static ERL_NIF_TERM am_backend;
static ERL_NIF_TERM am_midi1;
static ERL_NIF_TERM am_ump;
static ERL_NIF_TERM am_midi2;
static ERL_NIF_TERM am_virtual_in;
static ERL_NIF_TERM am_virtual_out;
static ERL_NIF_TERM g_backend_atom;

/* send/2 (slice 2): the tag atom for {error, {unsupported_status, B}}. B is an
 * integer in the tuple, never an atom — we never build atoms from runtime input. */
static ERL_NIF_TERM am_unsupported_status;

/* recv (arc3/slice1): the leading tag of {midi_in, Dev, Bytes, TsNanos}. */
static ERL_NIF_TERM am_midi_in;

/* ── Helpers ────────────────────────────────────────────────────────────── */

/* Map an mm_result to its atom. success is the caller's `ok`; the 7 errors map
 * to their lowercase atoms (DESIGN.md §6). Covers all 8 result codes. */
static ERL_NIF_TERM result_to_atom(mm_result r)
{
    switch (r) {
        case MM_SUCCESS:      return am_ok;
        case MM_ERROR:        return am_error;
        case MM_INVALID_ARG:  return am_invalid_arg;
        case MM_NO_BACKEND:   return am_no_backend;
        case MM_OUT_OF_RANGE: return am_out_of_range;
        case MM_ALREADY_OPEN: return am_already_open;
        case MM_NOT_OPEN:     return am_not_open;
        case MM_ALLOC_FAILED: return am_alloc_failed;
        default:              return am_error;
    }
}

/* The single cleanup path shared by context_close/1 and the destructor.
 * Returns 1 if it performed the uninit, 0 if the context was already not live.
 * Idempotent: safe to call after an explicit close (no double uninit). */
static int do_uninit(midiio_ctx_res *res)
{
    int did = 0;
    enif_mutex_lock(g_uninit_lock);
    if (res->live) {
        mm_context_uninit(&res->ctx);
        res->live = 0;
        atomic_fetch_add(&g_uninit_count, 1);
        did = 1;
    }
    enif_mutex_unlock(g_uninit_lock);
    return did;
}

static void dtor_context(ErlNifEnv *env, void *obj)
{
    (void)env;
    do_uninit((midiio_ctx_res *)obj);
}

/* The single guarded cleanup path for a device, shared by close/1 and the
 * destructor (so an explicit close followed by GC does not double-free). Order
 * matters: close the OS port first (it references the context), then uninit the
 * per-device context. Shares g_uninit_lock + g_uninit_count with do_uninit, so
 * the GC test counts a device's context uninit the same way. Returns 1 if it ran,
 * 0 if the device was already not live. */
static int do_dev_cleanup(midiio_dev_res *res)
{
    if (res->lock == NULL)
        return 0; /* never fully constructed (mutex create failed at open) */

    /* The lock guards ONLY the live flip (exactly-once) and the kept snapshot.
     * The teardown — including mm_in_stop, which on ALSA pthread_joins the recv
     * thread — runs OUTSIDE the lock, because recv_cb needs this same lock to read
     * `owner`. Joining while holding it deadlocks close-vs-active-delivery (S1).
     * The keep is still held across the teardown (released LAST), so an in-flight
     * recv_cb keeps `res` valid until the join completes. */
    enif_mutex_lock(res->lock);
    int was_live = res->live;
    if (was_live)
        res->live = 0;
    int release_keep = 0;
    if (res->kept) {
        res->kept = 0;
        release_keep = 1;
    }
    enif_mutex_unlock(res->lock);

    if (was_live) {
        if (res->is_input) {
            mm_in_stop(&res->dev);    /* joins the recv thread — outside the lock */
            mm_in_close(&res->dev);
        } else {
            mm_out_close(&res->dev);
        }
        mm_context_uninit(&res->ctx); /* port first, then the per-device context */
        atomic_fetch_add(&g_uninit_count, 1);
    }

    /* Release the recv-thread keep LAST — after the join, so `res` stayed valid
     * for any in-flight callback. Exactly once (kept-guarded above). */
    if (release_keep)
        enif_release_resource(res);
    return was_live;
}

/* The owner-death monitor's down callback (S2 close). Fires when a monitored
 * owner process dies. The resource is guaranteed alive for the duration of this
 * callback (the runtime holds it), so reclaiming via the guarded do_dev_cleanup
 * is safe: it stops the recv thread (join outside the lock, Fix 1), closes, and
 * releases the keep LAST — `res` is not touched after that release. The monitor
 * has already fired, so no demonitor is needed. Idempotent with an explicit
 * close: both funnel through the live-guarded do_dev_cleanup, so exactly one
 * tears down (nif-resources: resource alive during down → let the dtor reclaim). */
static void down_device(ErlNifEnv *env, void *obj, ErlNifPid *pid, ErlNifMonitor *mon)
{
    (void)env;
    (void)pid;
    (void)mon;
    midiio_dev_res *res = (midiio_dev_res *)obj;
    res->monitored = 0;
    do_dev_cleanup(res);
}

static void dtor_device(ErlNifEnv *env, void *obj)
{
    (void)env;
    midiio_dev_res *res = (midiio_dev_res *)obj;
    do_dev_cleanup(res);
    /* The mutex is a field of the resource the VM is about to free, so destroy it
     * LAST — after the final cleanup, never while held (nif-resources). */
    if (res->lock != NULL) {
        enif_mutex_destroy(res->lock);
        res->lock = NULL;
    }
}

/* ── load / upgrade ─────────────────────────────────────────────────────────
 * The NIF .so is loaded once and persists; a BEAM-module reload (e.g. cover
 * instrumentation, or a hot upgrade) re-runs -on_load against the same library,
 * which fires `upgrade` rather than `load`. Because the same .so is reused, the
 * module-level statics (g_ctx_res_type, g_uninit_lock, the am_* atoms,
 * g_uninit_count) are SHARED across module instances (ERTS: "sharing the dynamic
 * library means static data is shared as well"). So:
 *   - the resource type must be *taken over* on upgrade, not re-created
 *     (ERL_NIF_RT_TAKEOVER) — the new instance inherits existing resources and
 *     its dtor applies to them (nif-lifecycle / nif-resources cards);
 *   - the mutex is created once and reused (re-creating it would leak the old
 *     one and dangle the count); and
 *   - `unload` stays NULL — freeing the shared mutex when the old instance is
 *     purged would dangle the live instance that still holds it.
 * init_statics() is the single shared path so load and upgrade cannot diverge.
 */
static int init_statics(ErlNifEnv *env, ErlNifResourceFlags flags)
{
    ErlNifResourceType *rt = enif_open_resource_type(
        env, NULL, "midiio_context", dtor_context, flags, NULL);
    if (rt == NULL)
        return -1;
    g_ctx_res_type = rt;

    /* The device type carries a `down` callback (S2: reclaim a started input
     * whose owner died), so it is registered via the init-struct form
     * (enif_init_resource_type). members=3 → {dtor, stop, down}; stop is NULL.
     * Same flags as the context type (CREATE on load, TAKEOVER on upgrade) so it
     * survives the F1 reload path. */
    ErlNifResourceTypeInit dev_init = {
        dtor_device,  /* dtor */
        NULL,         /* stop */
        down_device,  /* down */
        3,            /* members: dtor + stop + down are provided */
        NULL          /* dyncall */
    };
    ErlNifResourceType *dt = enif_init_resource_type(
        env, "midiio_device", &dev_init, flags, NULL);
    if (dt == NULL)
        return -1;
    g_dev_res_type = dt;

    /* Created on first load; reused (not re-created) on takeover. */
    if (g_uninit_lock == NULL) {
        g_uninit_lock = enif_mutex_create("midiio_uninit_lock");
        if (g_uninit_lock == NULL)
            return -1;
    }

    /* Atoms are global and immutable; (re-)deriving them is idempotent. */
    am_ok           = enif_make_atom(env, "ok");
    am_error        = enif_make_atom(env, "error");
    am_invalid_arg  = enif_make_atom(env, "invalid_arg");
    am_no_backend   = enif_make_atom(env, "no_backend");
    am_out_of_range = enif_make_atom(env, "out_of_range");
    am_already_open = enif_make_atom(env, "already_open");
    am_not_open     = enif_make_atom(env, "not_open");
    am_alloc_failed = enif_make_atom(env, "alloc_failed");

    am_true         = enif_make_atom(env, "true");
    am_false        = enif_make_atom(env, "false");
    am_backend      = enif_make_atom(env, "backend");
    am_midi1        = enif_make_atom(env, "midi1");
    am_ump          = enif_make_atom(env, "ump");
    am_midi2        = enif_make_atom(env, "midi2");
    am_virtual_in   = enif_make_atom(env, "virtual_in");
    am_virtual_out  = enif_make_atom(env, "virtual_out");
    g_backend_atom  = enif_make_atom(env, MIDIIO_BACKEND);

    am_unsupported_status = enif_make_atom(env, "unsupported_status");
    am_midi_in            = enif_make_atom(env, "midi_in");
    am_owner_not_alive    = enif_make_atom(env, "owner_not_alive");

    return 0;
}

static int load(ErlNifEnv *env, void **priv_data, ERL_NIF_TERM load_info)
{
    (void)priv_data;
    (void)load_info;
    return init_statics(env, ERL_NIF_RT_CREATE);
}

static int upgrade(ErlNifEnv *env, void **priv_data, void **old_priv_data,
                   ERL_NIF_TERM load_info)
{
    (void)priv_data;
    (void)old_priv_data;
    (void)load_info;
    return init_statics(env, ERL_NIF_RT_TAKEOVER);
}

/* ── NIFs ───────────────────────────────────────────────────────────────── */

/* context_open() -> {ok, Ctx} | {error, Atom}
 * mm_context_init is a fast OS call (MIDIClientCreate / snd_seq_open), well
 * under 1 ms — a regular, non-dirty NIF. */
static ERL_NIF_TERM context_open(ErlNifEnv *env, int argc,
                                 const ERL_NIF_TERM argv[])
{
    (void)argc;
    (void)argv;

    midiio_ctx_res *res =
        enif_alloc_resource(g_ctx_res_type, sizeof(midiio_ctx_res));
    if (res == NULL)
        return enif_make_tuple2(env, am_error, am_alloc_failed);
    res->live = 0;

    mm_result r = mm_context_init(&res->ctx, NULL);
    if (r != MM_SUCCESS) {
        /* Drops the only reference; the destructor runs and, seeing live == 0,
         * does not uninit a context that never initialized. */
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }
    res->live = 1;

    ERL_NIF_TERM term = enif_make_resource(env, res);
    enif_release_resource(res); /* Erlang term is now the sole owner */
    return enif_make_tuple2(env, am_ok, term);
}

/* context_close(Ctx) -> ok | {error, not_open}
 * Bad arg (not a midiio_context resource) crashes with badarg — let it crash. */
static ERL_NIF_TERM context_close(ErlNifEnv *env, int argc,
                                  const ERL_NIF_TERM argv[])
{
    (void)argc;

    midiio_ctx_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_ctx_res_type, (void **)&res))
        return enif_make_badarg(env);

    if (do_uninit(res))
        return am_ok;
    return enif_make_tuple2(env, am_error, am_not_open);
}

/* result_atom(Code) -> atom()
 * Test/introspection NIF: exposes result_to_atom so eunit can assert the full
 * mm_result -> atom mapping (ledger row 9). Not part of the device API.
 * NOTE (F2, disclosed-deferred in arc1/slice5): gating this and uninit_count/0
 * out of the default build via -DMIDIIO_TEST was attempted and reverted — pc
 * builds one shared .so in the source tree across profiles, so a test-only NIF
 * set makes load_nif order-dependent. See slice5 closing report for re-entry. */
static ERL_NIF_TERM result_atom(ErlNifEnv *env, int argc,
                                const ERL_NIF_TERM argv[])
{
    (void)argc;

    int code;
    if (!enif_get_int(env, argv[0], &code))
        return enif_make_badarg(env);
    return result_to_atom((mm_result)code);
}

/* uninit_count() -> integer()
 * Test/introspection NIF: the global count of mm_context_uninit calls, so eunit
 * can verify the destructor runs exactly once on GC (ledger row 7). */
static ERL_NIF_TERM uninit_count(ErlNifEnv *env, int argc,
                                 const ERL_NIF_TERM argv[])
{
    (void)argc;
    (void)argv;

    return enif_make_int(env, atomic_load(&g_uninit_count));
}

/* seam_roundtrip(Bytes) -> {ok, Bytes2} | {error, unsupported_status}
 * Test NIF (arc3/slice2, PropEr): drive a message through BOTH raw seams purely —
 * midiio_bytes_to_msg (outbound parse) then midiio_msg_to_bytes (inbound build),
 * no I/O. It is in the shipped surface (compile-time gating out of the single
 * shared .so isn't robust — L18; see rebar.config), but it is MEMORY-SAFE:
 * midiio_bytes_to_msg self-defends on length (S2 remediation Fix 1), so this
 * caller-skips-the-pre-check entry point cannot read OOB for any input. */
static ERL_NIF_TERM seam_roundtrip(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;

    ErlNifBinary in;
    if (!enif_inspect_binary(env, argv[0], &in) || in.size == 0)
        return enif_make_badarg(env);

    mm_message m;
    if (!midiio_bytes_to_msg(in.data, in.size, &m))
        return enif_make_tuple2(env, am_error, am_unsupported_status);

    ERL_NIF_TERM out;
    if (m.type == MM_SYSEX) {
        unsigned char *p = enif_make_new_binary(env, m.sysex_size, &out);
        if (m.sysex_size > 0)
            memcpy(p, m.sysex, m.sysex_size);
    } else {
        uint8_t  buf[3];
        size_t   n = midiio_msg_to_bytes(&m, buf);
        unsigned char *p = enif_make_new_binary(env, n, &out);
        memcpy(p, buf, n);
    }
    return enif_make_tuple2(env, am_ok, out);
}

/* ── Enumeration + capabilities (slice 3, read-only) ────────────────────────── */

static ERL_NIF_TERM bool_atom(int truthy)
{
    return truthy ? am_true : am_false;
}

/* Build [{Index, NameBin}, …] in ascending index order for a count/name pair.
 * Every in-range index gets an entry even if its name lookup fails — minimidio
 * writes a placeholder (e.g. CoreMIDI's "(unknown)") and dropping the entry
 * would desync the index from reality. The name buffer is pre-NUL'd so a
 * non-writing failure yields an empty binary rather than garbage. Index is a
 * display-only snapshot ordinal (DESIGN §5), not identity. */
typedef uint32_t  (*mm_count_fn)(mm_context *);
typedef mm_result (*mm_name_fn)(mm_context *, uint32_t, char *, size_t);

static ERL_NIF_TERM enumerate(ErlNifEnv *env, mm_context *ctx,
                              mm_count_fn count_fn, mm_name_fn name_fn)
{
    uint32_t     n    = count_fn(ctx);
    ERL_NIF_TERM list = enif_make_list(env, 0); /* [] */

    /* Descend so head-first cons yields ascending 0..n-1. */
    for (uint32_t i = n; i-- > 0;) {
        char buf[256];
        buf[0] = '\0';
        (void)name_fn(ctx, i, buf, sizeof buf); /* entry kept regardless of result */

        size_t         len = strlen(buf);
        ERL_NIF_TERM   name_bin;
        unsigned char *p = enif_make_new_binary(env, len, &name_bin);
        memcpy(p, buf, len);

        ERL_NIF_TERM cell = enif_make_tuple2(env, enif_make_uint(env, i), name_bin);
        list = enif_make_list_cell(env, cell, list);
    }
    return list;
}

/* list_inputs(Ctx) -> [{Index, Name}] — fresh query, no caching. */
static ERL_NIF_TERM list_inputs(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;
    midiio_ctx_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_ctx_res_type, (void **)&res))
        return enif_make_badarg(env);
    return enumerate(env, &res->ctx, mm_in_count, mm_in_name);
}

/* list_outputs(Ctx) -> [{Index, Name}] — fresh query, no caching. */
static ERL_NIF_TERM list_outputs(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;
    midiio_ctx_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_ctx_res_type, (void **)&res))
        return enif_make_badarg(env);
    return enumerate(env, &res->ctx, mm_out_count, mm_out_name);
}

/* caps(Ctx) -> #{backend := atom(), <flag> := boolean(), …}
 * backend is the compile-time platform atom; flags decode mm_context_caps. */
static ERL_NIF_TERM caps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;
    midiio_ctx_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_ctx_res_type, (void **)&res))
        return enif_make_badarg(env);

    uint32_t     c = mm_context_caps(&res->ctx);
    ERL_NIF_TERM m = enif_make_new_map(env);
    enif_make_map_put(env, m, am_backend,     g_backend_atom,                  &m);
    enif_make_map_put(env, m, am_midi1,       bool_atom(c & MM_CAP_MIDI1),     &m);
    enif_make_map_put(env, m, am_ump,         bool_atom(c & MM_CAP_UMP),       &m);
    enif_make_map_put(env, m, am_midi2,       bool_atom(c & MM_CAP_MIDI2),     &m);
    enif_make_map_put(env, m, am_virtual_in,  bool_atom(c & MM_CAP_VIRTUAL_IN),  &m);
    enif_make_map_put(env, m, am_virtual_out, bool_atom(c & MM_CAP_VIRTUAL_OUT), &m);
    return m;
}

/* ── Device lifecycle (arc 2 slice 1 output; arc 3 slice 1 input) ────────────
 * mm_context_init / mm_out_open / mm_in_open are fast OS calls, well under 1 ms —
 * regular NIFs, not dirty (only send/2 is dirty). */

/* Allocate a device resource with its per-device lock created (F1). Zeroes the
 * struct (live=0, kept=0, owner inert), sets is_input. Returns NULL on alloc
 * failure (caller returns {error, alloc_failed}). live flips to 1 only on a
 * fully successful open (device_ok). */
static midiio_dev_res *new_device(int is_input)
{
    midiio_dev_res *res =
        enif_alloc_resource(g_dev_res_type, sizeof(midiio_dev_res));
    if (res == NULL)
        return NULL;
    memset(res, 0, sizeof *res);
    res->is_input = is_input;
    res->lock = enif_mutex_create("midiio_dev_lock");
    if (res->lock == NULL) {
        enif_release_resource(res); /* dtor: do_dev_cleanup no-ops (lock NULL) */
        return NULL;
    }
    return res;
}

/* Finalize a successfully-opened device resource into {ok, Dev}. */
static ERL_NIF_TERM device_ok(ErlNifEnv *env, midiio_dev_res *res)
{
    res->live = 1;
    ERL_NIF_TERM term = enif_make_resource(env, res);
    enif_release_resource(res); /* Erlang term is now the sole owner */
    return enif_make_tuple2(env, am_ok, term);
}

/* The recv callback (arc3/slice1 crux). Runs on minimidio's backend thread (NOT
 * an ERTS scheduler), userdata = the kept midiio_dev_res*. Builds one
 * {midi_in, Dev, <<Bytes>>, TsNanos} in a process-independent env and delivers it
 * to the owner via enif_send. Touches no scheduler state — only the per-device
 * lock (for owner), a fresh env, and enif_send (nif-thread-safety / L03/L04). */
static void recv_cb(mm_device *dev, const mm_message *msg, void *userdata)
{
    (void)dev;
    midiio_dev_res *res = (midiio_dev_res *)userdata;

    ErlNifEnv *menv = enif_alloc_env();

    /* Bytes via the inbound seam. SysEx (msg->sysex) is callback-lifetime only,
     * so memcpy it into the binary HERE, never alias it (L04). */
    ERL_NIF_TERM bytes;
    if (msg->type == MM_SYSEX) {
        unsigned char *p = enif_make_new_binary(menv, msg->sysex_size, &bytes);
        if (msg->sysex_size > 0)
            memcpy(p, msg->sysex, msg->sysex_size);
    } else {
        uint8_t  buf[3];
        size_t   n = midiio_msg_to_bytes(msg, buf);
        unsigned char *p = enif_make_new_binary(menv, n, &bytes);
        memcpy(p, buf, n);
    }

    /* Timestamp: minimidio reports seconds (mach / CLOCK_MONOTONIC since boot —
     * the struct's "since open" comment is wrong, R5). Emit host-monotonic int64
     * nanoseconds; 0/absent → 0 ("now"). */
    ErlNifSInt64 ts_ns = (ErlNifSInt64)(msg->timestamp * 1.0e9);

    ERL_NIF_TERM term = enif_make_tuple4(
        menv, am_midi_in, enif_make_resource(menv, res), bytes,
        enif_make_int64(menv, ts_ns));

    enif_mutex_lock(res->lock);          /* owner is written by set_owner/2 */
    ErlNifPid owner = res->owner;
    enif_mutex_unlock(res->lock);

    enif_send(NULL, &owner, menv, term); /* NULL caller_env: not an ERTS thread */
    enif_free_env(menv);                 /* env invalidated by the send */
}

/* open_output(Index) -> {ok, Dev} | {error, Atom}. Per-device legibly-named
 * context. Partial-failure: uninit the context if the port open fails. */
static ERL_NIF_TERM open_output(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;

    unsigned int idx;
    if (!enif_get_uint(env, argv[0], &idx))
        return enif_make_badarg(env);

    midiio_dev_res *res = new_device(0);
    if (res == NULL)
        return enif_make_tuple2(env, am_error, am_alloc_failed);

    char name[64];
    snprintf(name, sizeof name, "midiio-out:%u", idx);

    mm_result r = mm_context_init(&res->ctx, name);
    if (r != MM_SUCCESS) {
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }

    r = mm_out_open(&res->ctx, &res->dev, idx);
    if (r != MM_SUCCESS) {
        mm_context_uninit(&res->ctx); /* don't leak the context on partial failure */
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }

    return device_ok(env, res);
}

/* open_output_virtual() -> {ok, Dev} | {error, Atom}. Test scaffolding: a virtual
 * output source (no destination needed) — also the inbound loopback's stimulus. */
static ERL_NIF_TERM open_output_virtual(ErlNifEnv *env, int argc,
                                        const ERL_NIF_TERM argv[])
{
    (void)argc;
    (void)argv;

    midiio_dev_res *res = new_device(0);
    if (res == NULL)
        return enif_make_tuple2(env, am_error, am_alloc_failed);

    mm_result r = mm_context_init(&res->ctx, "midiio-out:virtual");
    if (r != MM_SUCCESS) {
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }

    r = mm_out_open_virtual(&res->ctx, &res->dev);
    if (r != MM_SUCCESS) {
        mm_context_uninit(&res->ctx);
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }

    return device_ok(env, res);
}

/* open_input(Index, Owner) -> {ok, Dev} | {error, Atom}. Per-device context;
 * registers recv_cb (userdata = res) and enif_keep_resource so the backend thread
 * holds res across the callback (released after mm_in_stop). Partial-failure as
 * for open_output. */
static ERL_NIF_TERM open_input(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;

    unsigned int idx;
    if (!enif_get_uint(env, argv[0], &idx))
        return enif_make_badarg(env);
    ErlNifPid owner;
    if (!enif_get_local_pid(env, argv[1], &owner))
        return enif_make_badarg(env);

    midiio_dev_res *res = new_device(1);
    if (res == NULL)
        return enif_make_tuple2(env, am_error, am_alloc_failed);
    res->owner = owner;

    char name[64];
    snprintf(name, sizeof name, "midiio-in:%u", idx);

    mm_result r = mm_context_init(&res->ctx, name);
    if (r != MM_SUCCESS) {
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }

    r = mm_in_open(&res->ctx, &res->dev, idx, recv_cb, res);
    if (r != MM_SUCCESS) {
        mm_context_uninit(&res->ctx);
        enif_release_resource(res);
        return enif_make_tuple2(env, am_error, result_to_atom(r));
    }

    /* The backend thread now holds res as userdata: keep it across the callback's
     * lifetime. Released exactly once after mm_in_stop (stop_input/close/dtor/down). */
    enif_keep_resource(res);
    res->kept = 1;

    /* S2: monitor the owner so a started-and-abandoned input (owner drops the
     * handle without stop/close) is reclaimed via down_device, not leaked. A
     * non-zero return means no monitor was armed — >0 is "owner already dead", so
     * reclaim now and report not_open (the device cannot serve a dead owner). */
    if (enif_monitor_process(env, res, &res->owner, &res->monitor) != 0) {
        do_dev_cleanup(res); /* stops/closes + releases the keep */
        return enif_make_tuple2(env, am_error, am_not_open);
    }
    res->monitored = 1;

    return device_ok(env, res);
}

/* start_input(Dev) -> ok | {error, atom()}. Connects the source so callbacks
 * flow (mm_in_start). */
static ERL_NIF_TERM start_input(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;
    midiio_dev_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_dev_res_type, (void **)&res))
        return enif_make_badarg(env);

    enif_mutex_lock(res->lock);
    mm_result r = (res->live && res->is_input) ? mm_in_start(&res->dev) : MM_NOT_OPEN;
    enif_mutex_unlock(res->lock);

    return (r == MM_SUCCESS) ? am_ok
                             : enif_make_tuple2(env, am_error, result_to_atom(r));
}

/* stop_input(Dev) -> ok | {error, atom()}. mm_in_stop (no more callbacks), THEN
 * release the recv-thread keep — guarded so a double stop is a clean no-op and
 * the keep is released exactly once. */
static ERL_NIF_TERM stop_input(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;
    midiio_dev_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_dev_res_type, (void **)&res))
        return enif_make_badarg(env);

    int release_keep = 0;
    enif_mutex_lock(res->lock);
    if (res->live && res->is_input)
        mm_in_stop(&res->dev);
    if (res->kept) {
        res->kept = 0;
        release_keep = 1;
    }
    enif_mutex_unlock(res->lock);

    if (release_keep)
        enif_release_resource(res); /* after stop: no callback can fire now */
    return am_ok;
}

/* set_owner(Dev, Pid) -> ok | {error, owner_not_alive}. Re-points an input's
 * owner-death monitor ATOMICALLY (R1/R2 hardening, arc3/slice2): arm the new
 * monitor into a LOCAL ErlNifMonitor first, and only commit (drop the old, store
 * the new, write owner) if it succeeds. On failure the old owner + monitor are
 * left fully intact and we return {error, owner_not_alive} — so handing off to an
 * already-dead pid can no longer disarm a good monitor or silently leak (R2), and
 * a racing old-owner death after a successful re-point hits the new monitor, not a
 * spurious cleanup (R1 narrows to the irreducible ERTS demonitor window). All
 * under the lock so the recv thread never reads a half-updated owner. Outputs
 * (is_input false) never monitor — they just write owner. */
static ERL_NIF_TERM set_owner(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;
    midiio_dev_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_dev_res_type, (void **)&res))
        return enif_make_badarg(env);
    ErlNifPid pid;
    if (!enif_get_local_pid(env, argv[1], &pid))
        return enif_make_badarg(env);

    enif_mutex_lock(res->lock);
    if (res->is_input) {
        ErlNifMonitor new_mon;
        /* >0 = target not alive, <0 = no down callback (can't happen — we set one).
         * Calling this under res->lock is safe: a dead target returns synchronously
         * with no down; a live target's down is async and would just block on the
         * lock until we release (no join on this path → no deadlock). */
        if (enif_monitor_process(env, res, &pid, &new_mon) != 0) {
            enif_mutex_unlock(res->lock); /* old owner + monitor untouched */
            return enif_make_tuple2(env, am_error, am_owner_not_alive);
        }
        if (res->monitored)
            enif_demonitor_process(env, res, &res->monitor);
        res->monitor   = new_mon;
        res->monitored = 1;
    }
    res->owner = pid;
    enif_mutex_unlock(res->lock);
    return am_ok;
}

/* close(Dev) -> ok | {error, not_open}
 * The unified device close (output now; input reuses it in arc 3). Bad handle
 * (not a midiio_device resource) crashes with badarg — let it crash. */
static ERL_NIF_TERM close_device(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;

    midiio_dev_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_dev_res_type, (void **)&res))
        return enif_make_badarg(env);

    if (do_dev_cleanup(res))
        return am_ok;
    return enif_make_tuple2(env, am_error, am_not_open);
}

/* send(Dev, Bytes) -> ok | {error, not_open} | {error, {unsupported_status, B}}
 *                   | {error, invalid_arg}            (slice 2, DESIGN §1/§6)
 *
 * This wrapper does arg/handle checking, the R1 length validation, and the
 * liveness gate; the byte→mm_result translation lives entirely behind the seam
 * (midiio_dev_send_raw, midiio_send.h). It is registered ERL_NIF_DIRTY_JOB_IO_BOUND
 * (D3): ALSA's send ends in snd_seq_drain_output, which can block under
 * backpressure, and a blocking syscall can't be yielded — dirty I/O is the
 * correct scheduler. The per-device process serializes calls into one device, so
 * there is no lock on this path (DESIGN §2/§4).
 *
 * Error vs. crash asymmetry (§6): failures we can name are tagged; malformed
 * input — which means the encoder above us (midilib) is broken — is let-crash via
 * badarg, decided here BEFORE the seam so the crash is clean Erlang-side. We do
 * NOT wrap the adapter in a catch-all that would swallow those bugs. */
static ERL_NIF_TERM send_nif(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[])
{
    (void)argc;

    midiio_dev_res *res = NULL;
    if (!enif_get_resource(env, argv[0], g_dev_res_type, (void **)&res))
        return enif_make_badarg(env);          /* foreign handle → let it crash */

    ErlNifBinary bin;
    if (!enif_inspect_binary(env, argv[1], &bin))
        return enif_make_badarg(env);          /* not a binary → let it crash */

    /* Malformed framing → let it crash (§6). Decided before the seam. */
    if (bin.size == 0)
        return enif_make_badarg(env);          /* empty: no status byte */

    uint8_t b = bin.data[0];
    if (b < 0x80)
        return enif_make_badarg(env);          /* leading data byte: not status-complete */

    if (b == 0xF0) {
        /* SysEx is variable-length; require at least 0xF0 0xF7. The upper bound
         * (> MM_SYSEX_BUF_SIZE) is minimidio's to report as invalid_arg. */
        if (bin.size < 2)
            return enif_make_badarg(env);
    } else {
        int exp = midiio_expected_len(b);
        if (exp != 0 && bin.size != (size_t)exp)
            return enif_make_badarg(env);      /* known status, wrong length */
        /* exp == 0 here ⇒ an unframable status (0xF4/F5/F7/F9/FD); we can't know
         * its length, so we don't validate it — the seam returns the
         * unsupported-status sentinel, a tagged/diagnosable error (not a crash). */
    }

    /* F1 close: the live-check AND the handle use run under the per-device lock,
     * so a concurrent close/1 (a second process sharing this device()) cannot
     * tear the port/context down between the check and the mm_out_send* deref —
     * the use-after-free the unlocked slice-2 version had. The lock is per-device
     * and uncontended under the single-owner contract (DESIGN §4 D3); it only ever
     * serializes the pathological cross-process send-vs-close race. */
    enif_mutex_lock(res->lock);
    if (!res->live) {
        enif_mutex_unlock(res->lock);
        return enif_make_tuple2(env, am_error, am_not_open);
    }
    mm_result r = midiio_dev_send_raw(&res->dev, bin.data, bin.size);
    enif_mutex_unlock(res->lock);

    if (r == (mm_result)MIDIIO_UNSUPPORTED_STATUS)
        return enif_make_tuple2(env, am_error,
                   enif_make_tuple2(env, am_unsupported_status,
                                    enif_make_uint(env, b)));

    switch (r) {
        case MM_SUCCESS:     return am_ok;
        case MM_NOT_OPEN:    return enif_make_tuple2(env, am_error, am_not_open);
        case MM_INVALID_ARG: return enif_make_tuple2(env, am_error, am_invalid_arg);
        default:             return enif_make_tuple2(env, am_error, result_to_atom(r));
    }
}

static ErlNifFunc nif_funcs[] = {
    {"context_open",       0, context_open},
    {"context_close",      1, context_close},
    {"result_atom",        1, result_atom},
    {"uninit_count",       0, uninit_count},
    {"seam_roundtrip",     1, seam_roundtrip},
    {"list_inputs",        1, list_inputs},
    {"list_outputs",       1, list_outputs},
    {"caps",               1, caps},
    {"open_output",        1, open_output},
    {"open_output_virtual", 0, open_output_virtual},
    {"open_input",         2, open_input},
    {"start_input",        1, start_input},
    {"stop_input",         1, stop_input},
    {"set_owner",          2, set_owner},
    {"close",              1, close_device},
    /* The only dirty NIF so far: send blocks in the backend drain (D3). */
    {"send",               2, send_nif, ERL_NIF_DIRTY_JOB_IO_BOUND},
};

/* Args: module, funcs, load, reload(deprecated→NULL), upgrade, unload.
 * upgrade is non-NULL so a module reload (cover / hot upgrade) succeeds; unload
 * is NULL because the shared statics must outlive an old purged instance. */
ERL_NIF_INIT(midiio, nif_funcs, load, NULL, upgrade, NULL)