c_src/termios.c

// Harlock termios NIF.
//
// Direct POSIX termios + TIOCGWINSZ access on /dev/tty. Bypasses the
// `:os.cmd` path entirely — `:os.cmd` spawns subprocesses via the
// erl_child_setup helper which calls setsid(), detaching them from the
// BEAM's controlling terminal. From inside the BEAM process itself we
// retain access to /dev/tty, so termios calls work here.
//
// All NIFs are dirty (ERL_NIF_DIRTY_JOB_IO_BOUND) because tcsetattr can
// block on some pty implementations.

#include <erl_nif.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>

static ErlNifResourceType *TTY_FD_TYPE;

typedef struct {
    int fd;
    ErlNifPid owner;
} tty_fd_t;

static int owner_matches(ErlNifEnv *env, tty_fd_t *tty) {
    ErlNifPid caller;
    enif_self(env, &caller);
    return enif_compare_pids(&caller, &tty->owner) == 0;
}

// Called when the resource is GC'd OR when enif_select marks the fd as
// stopped. We must not close from the destructor if a select is still
// active — enif_select_read with stop tells us when it's safe via a
// stop callback. Simpler: register a stop callback that closes the fd
// once BEAM has finished its select bookkeeping.

static void tty_fd_stop(ErlNifEnv *env, void *obj, ErlNifEvent fd,
                        int is_direct_call) {
    (void)env;
    (void)obj;
    (void)is_direct_call;
    // BEAM has unregistered fd from its poller; safe to close.
    close(fd);
}

static void tty_fd_destructor(ErlNifEnv *env, void *obj) {
    tty_fd_t *tty = (tty_fd_t *)obj;
    if (tty->fd >= 0) {
        // Mark fd as stopped; BEAM calls tty_fd_stop when select bookkeeping
        // is done, which closes the fd. Marking is idempotent if we already
        // stopped it explicitly.
        enif_select(env, tty->fd, ERL_NIF_SELECT_STOP, obj, NULL,
                    enif_make_atom(env, "undefined"));
        tty->fd = -1;
    }
}

static int on_load(ErlNifEnv *env, void **priv_data, ERL_NIF_TERM load_info) {
    (void)priv_data;
    (void)load_info;
    ErlNifResourceTypeInit init = {
        .dtor = tty_fd_destructor,
        .stop = tty_fd_stop,
        .members = 2,
    };
    TTY_FD_TYPE = enif_open_resource_type_x(
        env, "harlock_tty_fd", &init,
        ERL_NIF_RT_CREATE | ERL_NIF_RT_TAKEOVER, NULL);
    if (TTY_FD_TYPE == NULL) return -1;
    return 0;
}

static ERL_NIF_TERM make_error(ErlNifEnv *env, const char *atom) {
    return enif_make_tuple2(
        env, enif_make_atom(env, "error"), enif_make_atom(env, atom));
}

static ERL_NIF_TERM make_error_errno(ErlNifEnv *env, int err) {
    const char *atom;
    switch (err) {
        case ENXIO:   atom = "no_tty"; break;
        case EACCES:  atom = "permission_denied"; break;
        case ENOENT:  atom = "no_device"; break;
        case EBADF:   atom = "bad_fd"; break;
        case ENOTTY:  atom = "not_a_tty"; break;
        case EINTR:   atom = "interrupted"; break;
        default:      atom = "errno"; break;
    }
    if (!strcmp(atom, "errno")) {
        return enif_make_tuple2(
            env, enif_make_atom(env, "error"),
            enif_make_tuple2(env, enif_make_atom(env, "errno"),
                             enif_make_int(env, err)));
    }
    return make_error(env, atom);
}

// open() -> {:ok, ref} | {:error, reason}
// Opens /dev/tty with O_NONBLOCK so read(2) returns EAGAIN instead of
// blocking when no data is available. Pair with arm_select/1 for
// readiness notifications.
static ERL_NIF_TERM open_nif(ErlNifEnv *env, int argc,
                             const ERL_NIF_TERM argv[]) {
    (void)argc;
    (void)argv;

    int fd = open("/dev/tty", O_RDWR | O_NOCTTY | O_NONBLOCK);
    if (fd < 0) {
        return make_error_errno(env, errno);
    }
    if (!isatty(fd)) {
        close(fd);
        return make_error(env, "not_a_tty");
    }

    tty_fd_t *tty = enif_alloc_resource(TTY_FD_TYPE, sizeof(tty_fd_t));
    tty->fd = fd;
    enif_self(env, &tty->owner);
    ERL_NIF_TERM ref = enif_make_resource(env, tty);
    enif_release_resource(tty);

    return enif_make_tuple2(env, enif_make_atom(env, "ok"), ref);
}

// close(ref) -> :ok
// Uses ERL_NIF_SELECT_STOP so BEAM unregisters the fd from its poller
// before the actual close(2) happens (via the stop callback).
static ERL_NIF_TERM close_nif(ErlNifEnv *env, int argc,
                              const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd >= 0) {
        enif_select(env, tty->fd, ERL_NIF_SELECT_STOP, tty, NULL,
                    enif_make_atom(env, "undefined"));
        tty->fd = -1;
    }
    return enif_make_atom(env, "ok");
}

// get(ref) -> {:ok, binary} | {:error, reason}
// The binary is the raw `struct termios` bytes — opaque to Elixir, used
// only as input to a subsequent set/2.
static ERL_NIF_TERM get_nif(ErlNifEnv *env, int argc,
                            const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd < 0) return make_error(env, "closed");

    struct termios t;
    if (tcgetattr(tty->fd, &t) < 0) {
        return make_error_errno(env, errno);
    }

    ErlNifBinary bin;
    if (!enif_alloc_binary(sizeof(t), &bin)) {
        return make_error(env, "alloc");
    }
    memcpy(bin.data, &t, sizeof(t));

    return enif_make_tuple2(
        env, enif_make_atom(env, "ok"), enif_make_binary(env, &bin));
}

// set(ref, binary) -> :ok | {:error, reason}
static ERL_NIF_TERM set_nif(ErlNifEnv *env, int argc,
                            const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd < 0) return make_error(env, "closed");

    ErlNifBinary bin;
    if (!enif_inspect_binary(env, argv[1], &bin)) {
        return enif_make_badarg(env);
    }
    if (bin.size != sizeof(struct termios)) {
        return make_error(env, "bad_size");
    }

    struct termios t;
    memcpy(&t, bin.data, sizeof(t));

    if (tcsetattr(tty->fd, TCSANOW, &t) < 0) {
        return make_error_errno(env, errno);
    }
    return enif_make_atom(env, "ok");
}

// set_raw(ref) -> :ok | {:error, reason}
// cfmakeraw + VMIN=1, VTIME=0 (block for one byte, no inter-byte timer).
static ERL_NIF_TERM set_raw_nif(ErlNifEnv *env, int argc,
                                const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd < 0) return make_error(env, "closed");

    struct termios t;
    if (tcgetattr(tty->fd, &t) < 0) {
        return make_error_errno(env, errno);
    }

    cfmakeraw(&t);
    t.c_cc[VMIN] = 1;
    t.c_cc[VTIME] = 0;

    if (tcsetattr(tty->fd, TCSANOW, &t) < 0) {
        return make_error_errno(env, errno);
    }
    return enif_make_atom(env, "ok");
}

// winsize(ref) -> {:ok, {rows, cols}} | {:error, reason}
static ERL_NIF_TERM winsize_nif(ErlNifEnv *env, int argc,
                                const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd < 0) return make_error(env, "closed");

    struct winsize ws;
    if (ioctl(tty->fd, TIOCGWINSZ, &ws) < 0) {
        return make_error_errno(env, errno);
    }

    ERL_NIF_TERM size =
        enif_make_tuple2(env, enif_make_uint(env, ws.ws_row),
                         enif_make_uint(env, ws.ws_col));
    return enif_make_tuple2(env, enif_make_atom(env, "ok"), size);
}

// arm_select(ref) -> :ok | {:error, reason}
// Registers the fd with the BEAM IO poller for read-ready events. When
// the fd becomes readable, BEAM sends {:tty_ready, ref} to the calling
// process. One-shot: each notification consumes the registration, so
// the caller re-arms after every read. The message is delivered to the
// calling process (pid=NULL).
static ERL_NIF_TERM arm_select_nif(ErlNifEnv *env, int argc,
                                   const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd < 0) return make_error(env, "closed");
    if (!owner_matches(env, tty)) return make_error(env, "not_owner");

    ErlNifEnv *msg_env = enif_alloc_env();
    ERL_NIF_TERM msg =
        enif_make_tuple2(msg_env, enif_make_atom(msg_env, "tty_ready"),
                         enif_make_copy(msg_env, argv[0]));

    int rc = enif_select_read(env, tty->fd, tty, NULL, msg, msg_env);

    // On success, BEAM takes ownership of msg_env. On failure, we own it
    // and must free.
    if (rc < 0) {
        enif_free_env(msg_env);
        return make_error(env, "select_failed");
    }

    return enif_make_atom(env, "ok");
}

// read_nonblock(ref, max_bytes) -> {:ok, binary} | :wouldblock | :eof |
//                                  {:error, reason}
// Non-blocking read(2) on the fd (the fd was opened with O_NONBLOCK).
// Returns :wouldblock if no data was ready (caller should re-arm select
// and wait). Otherwise returns whatever bytes were available.
static ERL_NIF_TERM read_nonblock_nif(ErlNifEnv *env, int argc,
                                      const ERL_NIF_TERM argv[]) {
    (void)argc;
    tty_fd_t *tty;
    if (!enif_get_resource(env, argv[0], TTY_FD_TYPE, (void **)&tty)) {
        return enif_make_badarg(env);
    }
    if (tty->fd < 0) return make_error(env, "closed");
    if (!owner_matches(env, tty)) return make_error(env, "not_owner");

    unsigned max;
    if (!enif_get_uint(env, argv[1], &max)) return enif_make_badarg(env);
    if (max == 0 || max > 65536) return enif_make_badarg(env);

    ErlNifBinary bin;
    if (!enif_alloc_binary(max, &bin)) return make_error(env, "alloc");

    ssize_t n;
    do {
        n = read(tty->fd, bin.data, max);
    } while (n < 0 && errno == EINTR);

    if (n < 0) {
        enif_release_binary(&bin);
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            return enif_make_atom(env, "wouldblock");
        }
        return make_error_errno(env, errno);
    }
    if (n == 0) {
        enif_release_binary(&bin);
        return enif_make_atom(env, "eof");
    }

    if ((size_t)n < bin.size) {
        enif_realloc_binary(&bin, n);
    }
    return enif_make_tuple2(env, enif_make_atom(env, "ok"),
                            enif_make_binary(env, &bin));
}

static ErlNifFunc nif_funcs[] = {
    {"open_nif",          0, open_nif,          ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"close_nif",         1, close_nif,         ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"get_nif",           1, get_nif,           ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"set_nif",           2, set_nif,           ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"set_raw_nif",       1, set_raw_nif,       ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"winsize_nif",       1, winsize_nif,       ERL_NIF_DIRTY_JOB_IO_BOUND},
    // arm_select needs to run on a normal scheduler (not dirty) because
    // enif_select_read requires the calling process to be the receiver.
    {"arm_select_nif",    1, arm_select_nif,    0},
    {"read_nonblock_nif", 2, read_nonblock_nif, ERL_NIF_DIRTY_JOB_IO_BOUND}};

ERL_NIF_INIT(Elixir.Harlock.Terminal.Termios, nif_funcs, on_load, NULL, NULL,
             NULL);