Packages

A pure-Elixir TUI framework for Unix terminals. TEA-style model / update / view loop on top of OTP, with first-class focus traversal, layout constraints, mouse support, ANSI cell-diff rendering, and a small termios NIF for direct /dev/tty control.

Current section

Files

Jump to
harlock c_src termios.c
Raw

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.
//
// The exec_* NIFs hand the terminal to another program. Programs the BEAM
// starts through ports get no controlling terminal (see above), so the
// program is started from here instead, via the harlock_exec helper, as the
// terminal's foreground process group — the way a shell runs a job.

#include <erl_nif.h>
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <spawn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>

extern char **environ;

static ErlNifResourceType *TTY_FD_TYPE;
static ErlNifResourceType *EXEC_TYPE;

// A program started by exec_start_nif. `status_fd` is the read end of the
// helper's status pipe; `pgid` is the helper's process group, which the
// program shares.
typedef struct {
    int status_fd;
    pid_t pgid;
    int finished;
} exec_t;

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 void exec_stop(ErlNifEnv *env, void *obj, ErlNifEvent fd,
                      int is_direct_call) {
    (void)env;
    (void)obj;
    (void)is_direct_call;
    close(fd);
}

// A program whose resource is collected before it finished is killed, so a
// crashed owner cannot leave it holding the terminal. Only while unfinished:
// once the helper has reported, the group id may already belong to someone
// else.
static void exec_destructor(ErlNifEnv *env, void *obj) {
    exec_t *ex = (exec_t *)obj;
    if (!ex->finished && ex->pgid > 0) kill(-ex->pgid, SIGKILL);
    if (ex->status_fd >= 0) {
        enif_select(env, ex->status_fd, ERL_NIF_SELECT_STOP, obj, NULL,
                    enif_make_atom(env, "undefined"));
        ex->status_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;

    ErlNifResourceTypeInit exec_init = {
        .dtor = exec_destructor,
        .stop = exec_stop,
        .members = 2,
    };
    EXEC_TYPE = enif_open_resource_type_x(
        env, "harlock_exec", &exec_init,
        ERL_NIF_RT_CREATE | ERL_NIF_RT_TAKEOVER, NULL);
    if (EXEC_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));
}

// -- Handing the terminal to another program --------------------------------

static const char *errno_atom(int err) {
    switch (err) {
        case ENOENT:    return "enoent";
        case EACCES:    return "eacces";
        case ENOTDIR:   return "enotdir";
        case ENOEXEC:   return "enoexec";
        case ENOMEM:    return "enomem";
        case E2BIG:     return "e2big";
        case EAGAIN:    return "eagain";
        case ETIMEDOUT: return "etimedout";
        case EPERM:     return "eperm";
        case EIO:       return "eio";
        default:        return NULL;
    }
}

static ERL_NIF_TERM errno_term(ErlNifEnv *env, int err) {
    const char *name = errno_atom(err);
    if (name) return enif_make_atom(env, name);
    return enif_make_tuple2(env, enif_make_atom(env, "errno"), enif_make_int(env, err));
}

static ERL_NIF_TERM error2(ErlNifEnv *env, const char *what, int err) {
    return enif_make_tuple2(
        env, enif_make_atom(env, "error"),
        enif_make_tuple2(env, enif_make_atom(env, what), errno_term(env, err)));
}

static char *dup_binary(ErlNifEnv *env, ERL_NIF_TERM term) {
    ErlNifBinary bin;
    if (!enif_inspect_binary(env, term, &bin)) return NULL;
    if (memchr(bin.data, '\0', bin.size)) return NULL;
    char *s = enif_alloc(bin.size + 1);
    memcpy(s, bin.data, bin.size);
    s[bin.size] = '\0';
    return s;
}

// A NULL-terminated array of C strings from a list of binaries, with `prefix`
// leading entries left for the caller to fill. NULL on a malformed list.
static char **dup_binary_list(ErlNifEnv *env, ERL_NIF_TERM list, unsigned prefix,
                              unsigned *count) {
    unsigned len;
    if (!enif_get_list_length(env, list, &len)) return NULL;
    char **out = enif_alloc(sizeof(char *) * (prefix + len + 1));
    memset(out, 0, sizeof(char *) * (prefix + len + 1));
    ERL_NIF_TERM head, tail = list;
    for (unsigned i = 0; i < len; i++) {
        enif_get_list_cell(env, tail, &head, &tail);
        if ((out[prefix + i] = dup_binary(env, head)) == NULL) {
            for (unsigned j = prefix; j < prefix + i; j++) enif_free(out[j]);
            enif_free(out);
            return NULL;
        }
    }
    *count = prefix + len;
    return out;
}

static void free_strings(char **strings, unsigned from, unsigned count) {
    if (!strings) return;
    for (unsigned i = from; i < count; i++) enif_free(strings[i]);
    enif_free(strings);
}

// Duplicate `fd` to a number above 3 with FD_CLOEXEC, closing the original.
// The helper's fd 3 is the status pipe; a source descriptor that happened to
// be 3 already would make that dup2 a no-op and leave FD_CLOEXEC set on it.
static int move_high_cloexec(int fd) {
    int high = fcntl(fd, F_DUPFD_CLOEXEC, 10);
    int saved = errno;
    close(fd);
    errno = saved;
    return high;
}

// exec_start(tty_ref, helper_path, dir, argv, env) ->
//     {:ok, exec_ref, os_pid} | {:error, reason}
//
// `argv` is a non-empty list of binaries, program first; `dir` is "" for the
// current directory; `env` is a list of "KEY=VALUE" binaries, or nil to
// inherit. The caller must be in the terminal's foreground group.
static ERL_NIF_TERM exec_start_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");

    // tcsetpgrp from a background group would stop the BEAM; refuse instead.
    if (tcgetpgrp(tty->fd) != getpgrp()) return make_error(env, "not_foreground");

    char *helper = dup_binary(env, argv[1]);
    char *dir = dup_binary(env, argv[2]);
    unsigned argv_count = 0, env_count = 0;
    char **helper_argv = dup_binary_list(env, argv[3], 2, &argv_count);
    char **envp = NULL;
    int env_ok = enif_is_atom(env, argv[4]) ||
                 (envp = dup_binary_list(env, argv[4], 0, &env_count)) != NULL;

    ERL_NIF_TERM result;
    if (!helper || !dir || !helper_argv || argv_count < 3 || !env_ok) {
        result = enif_make_badarg(env);
        goto done;
    }
    helper_argv[0] = helper;
    helper_argv[1] = dir;

    // A fresh descriptor for the program: the Reader's is O_NONBLOCK, and a
    // dup2 of it would hand the program a non-blocking stdin.
    int child_tty = open("/dev/tty", O_RDWR | O_NOCTTY);
    if (child_tty < 0) { result = error2(env, "open_tty", errno); goto done; }
    if ((child_tty = move_high_cloexec(child_tty)) < 0) {
        result = error2(env, "open_tty", errno);
        goto done;
    }

    int status_pipe[2];
    if (pipe(status_pipe) != 0) {
        result = error2(env, "pipe", errno);
        close(child_tty);
        goto done;
    }
    status_pipe[0] = move_high_cloexec(status_pipe[0]);
    status_pipe[1] = move_high_cloexec(status_pipe[1]);
    if (status_pipe[0] < 0 || status_pipe[1] < 0) {
        result = error2(env, "pipe", errno);
        if (status_pipe[0] >= 0) close(status_pipe[0]);
        if (status_pipe[1] >= 0) close(status_pipe[1]);
        close(child_tty);
        goto done;
    }
    fcntl(status_pipe[0], F_SETFL, O_NONBLOCK);

    posix_spawn_file_actions_t actions;
    posix_spawn_file_actions_init(&actions);
    posix_spawn_file_actions_adddup2(&actions, child_tty, STDIN_FILENO);
    posix_spawn_file_actions_adddup2(&actions, child_tty, STDOUT_FILENO);
    posix_spawn_file_actions_adddup2(&actions, child_tty, STDERR_FILENO);
    posix_spawn_file_actions_adddup2(&actions, status_pipe[1], 3);

    posix_spawnattr_t attr;
    posix_spawnattr_init(&attr);
    sigset_t defaults, empty;
    sigfillset(&defaults);
    sigdelset(&defaults, SIGKILL);
    sigdelset(&defaults, SIGSTOP);
    sigemptyset(&empty);
    posix_spawnattr_setsigdefault(&attr, &defaults);
    posix_spawnattr_setsigmask(&attr, &empty);
    posix_spawnattr_setpgroup(&attr, 0);
    short flags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK;
#ifdef POSIX_SPAWN_CLOEXEC_DEFAULT
    flags |= POSIX_SPAWN_CLOEXEC_DEFAULT;
#endif
    posix_spawnattr_setflags(&attr, flags);

    pid_t pid;
    int rc = posix_spawn(&pid, helper, &actions, &attr, helper_argv,
                         envp ? envp : environ);
    posix_spawn_file_actions_destroy(&actions);
    posix_spawnattr_destroy(&attr);
    close(child_tty);
    close(status_pipe[1]);

    if (rc != 0) {
        close(status_pipe[0]);
        result = error2(env, "spawn", rc);
        goto done;
    }

    if (tcsetpgrp(tty->fd, pid) != 0) {
        int err = errno;
        kill(-pid, SIGKILL);
        close(status_pipe[0]);
        result = error2(env, "foreground", err);
        goto done;
    }

    exec_t *ex = enif_alloc_resource(EXEC_TYPE, sizeof(exec_t));
    ex->status_fd = status_pipe[0];
    ex->pgid = pid;
    ex->finished = 0;
    ERL_NIF_TERM ref = enif_make_resource(env, ex);
    enif_release_resource(ex);
    result = enif_make_tuple3(env, enif_make_atom(env, "ok"), ref, enif_make_int(env, pid));

done:
    // helper_argv[0..1] are `helper` and `dir`; free those separately.
    if (helper_argv) free_strings(helper_argv, 2, argv_count);
    free_strings(envp, 0, env_count);
    if (helper) enif_free(helper);
    if (dir) enif_free(dir);
    return result;
}

// exec_arm(exec_ref) -> :ok | {:error, reason}
// One-shot readiness on the status pipe: the caller receives
// {:exec_ready, exec_ref} when the helper has reported or died.
static ERL_NIF_TERM exec_arm_nif(ErlNifEnv *env, int argc,
                                 const ERL_NIF_TERM argv[]) {
    (void)argc;
    exec_t *ex;
    if (!enif_get_resource(env, argv[0], EXEC_TYPE, (void **)&ex)) {
        return enif_make_badarg(env);
    }
    if (ex->status_fd < 0) return make_error(env, "closed");

    ErlNifEnv *msg_env = enif_alloc_env();
    ERL_NIF_TERM msg =
        enif_make_tuple2(msg_env, enif_make_atom(msg_env, "exec_ready"),
                         enif_make_copy(msg_env, argv[0]));
    if (enif_select_read(env, ex->status_fd, ex, NULL, msg, msg_env) < 0) {
        enif_free_env(msg_env);
        return make_error(env, "select_failed");
    }
    return enif_make_atom(env, "ok");
}

static void exec_finish(ErlNifEnv *env, exec_t *ex) {
    ex->finished = 1;
    if (ex->status_fd >= 0) {
        enif_select(env, ex->status_fd, ERL_NIF_SELECT_STOP, ex, NULL,
                    enif_make_atom(env, "undefined"));
        ex->status_fd = -1;
    }
}

// exec_read(exec_ref) ->
//     {:exited, code} | {:signaled, signal} | {:failed, stage, errno} |
//     :killed | :wouldblock | {:error, reason}
//
// :killed means the helper died without reporting — its process group was
// killed. Any result other than :wouldblock is final and closes the pipe.
static ERL_NIF_TERM exec_read_nif(ErlNifEnv *env, int argc,
                                  const ERL_NIF_TERM argv[]) {
    (void)argc;
    exec_t *ex;
    if (!enif_get_resource(env, argv[0], EXEC_TYPE, (void **)&ex)) {
        return enif_make_badarg(env);
    }
    if (ex->status_fd < 0) return make_error(env, "closed");

    char line[96];
    ssize_t n;
    do {
        n = read(ex->status_fd, line, sizeof(line) - 1);
    } while (n < 0 && errno == EINTR);

    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) return enif_make_atom(env, "wouldblock");
        int err = errno;
        exec_finish(env, ex);
        return make_error_errno(env, err);
    }
    exec_finish(env, ex);
    if (n == 0) return enif_make_atom(env, "killed");

    // The helper writes one line well under PIPE_BUF, so it arrives whole.
    line[n] = '\0';
    int value, err;
    char stage[24];
    if (sscanf(line, "exited %d", &value) == 1) {
        return enif_make_tuple2(env, enif_make_atom(env, "exited"), enif_make_int(env, value));
    }
    if (sscanf(line, "signaled %d", &value) == 1) {
        return enif_make_tuple2(env, enif_make_atom(env, "signaled"), enif_make_int(env, value));
    }
    if (sscanf(line, "failed %23s %d", stage, &err) == 2) {
        return enif_make_tuple3(env, enif_make_atom(env, "failed"),
                                enif_make_atom(env, stage), errno_term(env, err));
    }
    return make_error(env, "bad_status");
}

// exec_kill(exec_ref) -> :ok | {:error, :finished}
// SIGKILL to the whole process group: the helper and the program.
static ERL_NIF_TERM exec_kill_nif(ErlNifEnv *env, int argc,
                                  const ERL_NIF_TERM argv[]) {
    (void)argc;
    exec_t *ex;
    if (!enif_get_resource(env, argv[0], EXEC_TYPE, (void **)&ex)) {
        return enif_make_badarg(env);
    }
    if (ex->finished) return make_error(env, "finished");
    if (kill(-ex->pgid, SIGKILL) != 0 && errno != ESRCH) return make_error_errno(env, errno);
    return enif_make_atom(env, "ok");
}

// reclaim(tty_ref) -> :ok | {:error, reason}
//
// Make the BEAM's process group the terminal's foreground again. After a
// program ran, the BEAM is a background group, and tcsetpgrp from there
// raises SIGTTOU — which stops the whole VM under a job-control shell, and OTP
// cannot handle it. Blocking it in this thread is enough: the kernel checks
// the calling thread's mask.
static ERL_NIF_TERM reclaim_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");

    sigset_t block, saved;
    sigemptyset(&block);
    sigaddset(&block, SIGTTOU);
    pthread_sigmask(SIG_BLOCK, &block, &saved);
    int rc = tcsetpgrp(tty->fd, getpgrp());
    int err = errno;
    pthread_sigmask(SIG_SETMASK, &saved, NULL);

    return rc == 0 ? enif_make_atom(env, "ok") : make_error_errno(env, err);
}

// job_control(tty_ref) -> boolean
//
// Whether stopping this BEAM would hand the terminal back to a shell that can
// resume it. The kernel discards SIGTSTP sent to an orphaned process group —
// one with no member whose parent is in another group of the same session —
// and nothing would ever send the SIGCONT. So: this BEAM holds the foreground,
// and its parent is in the same session but a different process group, as an
// interactive shell running it as a job is.
static ERL_NIF_TERM job_control_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");

    pid_t parent = getppid();
    int ok = tcgetpgrp(tty->fd) == getpgrp() &&
             getsid(parent) == getsid(0) &&
             getpgid(parent) != getpgrp();
    return enif_make_atom(env, ok ? "true" : "false");
}

// suspend() -> :ok | {:error, reason}
// SIGTSTP to this BEAM's process group, as Ctrl-Z would send it. The caller
// sets SIGTSTP to its default disposition first and checks job_control/1.
static ERL_NIF_TERM suspend_nif(ErlNifEnv *env, int argc,
                                const ERL_NIF_TERM argv[]) {
    (void)argc;
    (void)argv;
    if (kill(0, SIGTSTP) != 0) return make_error_errno(env, errno);
    return enif_make_atom(env, "ok");
}

// foreground(tty_ref) -> boolean
static ERL_NIF_TERM foreground_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");
    return enif_make_atom(env, tcgetpgrp(tty->fd) == getpgrp() ? "true" : "false");
}

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},
    {"exec_start_nif",    5, exec_start_nif,    ERL_NIF_DIRTY_JOB_IO_BOUND},
    // Same constraint as arm_select: the caller is the notification target.
    {"exec_arm_nif",      1, exec_arm_nif,      0},
    {"exec_read_nif",     1, exec_read_nif,     ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"exec_kill_nif",     1, exec_kill_nif,     ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"reclaim_nif",       1, reclaim_nif,       ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"foreground_nif",    1, foreground_nif,    ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"job_control_nif",   1, job_control_nif,   ERL_NIF_DIRTY_JOB_IO_BOUND},
    {"suspend_nif",       0, suspend_nif,       ERL_NIF_DIRTY_JOB_IO_BOUND}};

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