From d7e560e5b309578f142c23467d566877cb54ee9a Mon Sep 17 00:00:00 2001 From: Thiago de Arruda Date: Mon, 23 Feb 2015 12:34:20 -0300 Subject: [PATCH] job: Allow spawning jobs connected to pseudo terminals --- CMakeLists.txt | 4 +- src/nvim/CMakeLists.txt | 1 + src/nvim/eval.c | 57 +++++++- src/nvim/globals.h | 1 + src/nvim/os/job.c | 11 ++ src/nvim/os/job_defs.h | 13 +- src/nvim/os/job_private.h | 21 ++- src/nvim/os/pty_process.c | 225 +++++++++++++++++++++++++++++ src/nvim/os/pty_process.h | 7 + test/functional/job/CMakeLists.txt | 2 + test/functional/job/job_spec.lua | 54 +++++++ test/functional/job/tty-test.c | 114 +++++++++++++++ 12 files changed, 501 insertions(+), 9 deletions(-) create mode 100644 src/nvim/os/pty_process.c create mode 100644 src/nvim/os/pty_process.h create mode 100644 test/functional/job/CMakeLists.txt create mode 100644 test/functional/job/tty-test.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 111e8b76d8709e..caf345de8cb49f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -276,6 +276,8 @@ get_compile_flags(NVIM_VERSION_CFLAGS) add_subdirectory(test/includes) add_subdirectory(config) +add_subdirectory(test/functional/job) # compile pty test program + # Setup some test-related bits. We do this after going down the tree because we # need some of the targets. @@ -316,5 +318,5 @@ if(BUSTED_PRG) -DBUILD_DIR=${CMAKE_BINARY_DIR} -DTEST_TYPE=functional -P ${PROJECT_SOURCE_DIR}/cmake/RunTests.cmake - DEPENDS nvim) + DEPENDS nvim tty-test) endif() diff --git a/src/nvim/CMakeLists.txt b/src/nvim/CMakeLists.txt index 1c2dad6094f221..c59de2b5de6794 100644 --- a/src/nvim/CMakeLists.txt +++ b/src/nvim/CMakeLists.txt @@ -175,6 +175,7 @@ list(APPEND NVIM_LINK_LIBRARIES ${LIBTERMKEY_LIBRARIES} ${LIBUNIBILIUM_LIBRARIES} m + util ${CMAKE_THREAD_LIBS_INIT} ) diff --git a/src/nvim/eval.c b/src/nvim/eval.c index c2a46ed206e1f0..b826ddcc503782 100644 --- a/src/nvim/eval.c +++ b/src/nvim/eval.c @@ -6488,8 +6488,9 @@ static struct fst { {"isdirectory", 1, 1, f_isdirectory}, {"islocked", 1, 1, f_islocked}, {"items", 1, 1, f_items}, + {"jobresize", 3, 3, f_jobresize}, {"jobsend", 2, 2, f_jobsend}, - {"jobstart", 2, 3, f_jobstart}, + {"jobstart", 2, 4, f_jobstart}, {"jobstop", 1, 1, f_jobstop}, {"join", 1, 2, f_join}, {"keys", 1, 1, f_keys}, @@ -10665,6 +10666,39 @@ static void f_jobsend(typval_T *argvars, typval_T *rettv) rettv->vval.v_number = job_write(job, buf); } +// "jobresize()" function +static void f_jobresize(typval_T *argvars, typval_T *rettv) +{ + rettv->v_type = VAR_NUMBER; + rettv->vval.v_number = 0; + + if (check_restricted() || check_secure()) { + return; + } + + if (argvars[0].v_type != VAR_NUMBER || argvars[1].v_type != VAR_NUMBER + || argvars[2].v_type != VAR_NUMBER) { + // job id, width, height + EMSG(_(e_invarg)); + return; + } + + Job *job = job_find(argvars[0].vval.v_number); + + if (!job) { + // Probably an invalid job id + EMSG(_(e_invjob)); + return; + } + + if (!job_resize(job, argvars[1].vval.v_number, argvars[2].vval.v_number)) { + EMSG(_(e_jobnotpty)); + return; + } + + rettv->vval.v_number = 1; +} + // "jobstart()" function static void f_jobstart(typval_T *argvars, typval_T *rettv) { @@ -10682,8 +10716,7 @@ static void f_jobstart(typval_T *argvars, typval_T *rettv) if (argvars[0].v_type != VAR_STRING || argvars[1].v_type != VAR_STRING - || (argvars[2].v_type != VAR_LIST - && argvars[2].v_type != VAR_UNKNOWN)) { + || (argvars[2].v_type != VAR_LIST && argvars[2].v_type != VAR_UNKNOWN)) { // Wrong argument types EMSG(_(e_invarg)); return; @@ -10731,6 +10764,24 @@ static void f_jobstart(typval_T *argvars, typval_T *rettv) opts.stdout_cb = on_job_stdout; opts.stderr_cb = on_job_stderr; opts.exit_cb = on_job_exit; + + if (args && argvars[3].v_type == VAR_DICT) { + dict_T *job_opts = argvars[3].vval.v_dict; + opts.pty = true; + uint16_t width = get_dict_number(job_opts, (uint8_t *)"width"); + if (width > 0) { + opts.width = width; + } + uint16_t height = get_dict_number(job_opts, (uint8_t *)"height"); + if (height > 0) { + opts.height = height; + } + char *term = (char *)get_dict_string(job_opts, (uint8_t *)"TERM", true); + if (term) { + opts.term_name = term; + } + } + job_start(opts, &rettv->vval.v_number); if (rettv->vval.v_number <= 0) { diff --git a/src/nvim/globals.h b/src/nvim/globals.h index e6a8ee33c56e82..c0d5217fc26c5f 100644 --- a/src/nvim/globals.h +++ b/src/nvim/globals.h @@ -1127,6 +1127,7 @@ EXTERN char_u e_isadir2[] INIT(= N_("E17: \"%s\" is a directory")); EXTERN char_u e_invjob[] INIT(= N_("E900: Invalid job id")); EXTERN char_u e_jobtblfull[] INIT(= N_("E901: Job table is full")); EXTERN char_u e_jobexe[] INIT(= N_("E902: \"%s\" is not an executable")); +EXTERN char_u e_jobnotpty[] INIT(= N_("E904: Job is not connected to a pty")); EXTERN char_u e_libcall[] INIT(= N_("E364: Library call failed for \"%s()\"")); EXTERN char_u e_markinval[] INIT(= N_("E19: Mark has invalid line number")); EXTERN char_u e_marknotset[] INIT(= N_("E20: Mark not set")); diff --git a/src/nvim/os/job.c b/src/nvim/os/job.c index 94bb9067ed7440..9855a19269326b 100644 --- a/src/nvim/os/job.c +++ b/src/nvim/os/job.c @@ -7,6 +7,7 @@ #include "nvim/os/job.h" #include "nvim/os/job_defs.h" #include "nvim/os/job_private.h" +#include "nvim/os/pty_process.h" #include "nvim/os/rstream.h" #include "nvim/os/rstream_defs.h" #include "nvim/os/wstream.h" @@ -320,6 +321,16 @@ void *job_data(Job *job) return job->opts.data; } +/// Resize the window for a pty job +bool job_resize(Job *job, uint16_t width, uint16_t height) +{ + if (!job->opts.pty) { + return false; + } + pty_process_resize(job, width, height); + return true; +} + /// Iterates the table, sending SIGTERM to stopped jobs and SIGKILL to those /// that didn't die from SIGTERM after a while(exit_timeout is 0). static void job_stop_timer_cb(uv_timer_t *handle) diff --git a/src/nvim/os/job_defs.h b/src/nvim/os/job_defs.h index 340ef551bee50c..ac9a37b366ff17 100644 --- a/src/nvim/os/job_defs.h +++ b/src/nvim/os/job_defs.h @@ -37,6 +37,13 @@ typedef struct { job_exit_cb exit_cb; // Maximum memory used by the job's WStream size_t maxmem; + // Connect the job to a pseudo terminal + bool pty; + // Initial window dimensions if the job is connected to a pseudo terminal + uint16_t width, height; + // Value for the $TERM environment variable. A default value of "ansi" is + // assumed if NULL + char *term_name; } JobOptions; #define JOB_OPTIONS_INIT ((JobOptions) { \ @@ -46,6 +53,10 @@ typedef struct { .stdout_cb = NULL, \ .stderr_cb = NULL, \ .exit_cb = NULL, \ - .maxmem = 0 \ + .maxmem = 0, \ + .pty = false, \ + .width = 80, \ + .height = 24, \ + .term_name = NULL \ }) #endif // NVIM_OS_JOB_DEFS_H diff --git a/src/nvim/os/job_private.h b/src/nvim/os/job_private.h index 1beaa1bd70fd12..b1d5e13feb7b48 100644 --- a/src/nvim/os/job_private.h +++ b/src/nvim/os/job_private.h @@ -8,6 +8,7 @@ #include "nvim/os/rstream_defs.h" #include "nvim/os/wstream_defs.h" #include "nvim/os/pipe_process.h" +#include "nvim/os/pty_process.h" #include "nvim/os/shell.h" #include "nvim/log.h" @@ -45,12 +46,16 @@ extern uv_timer_t job_stop_timer; static inline bool process_spawn(Job *job) { - return pipe_process_spawn(job); + return job->opts.pty ? pty_process_spawn(job) : pipe_process_spawn(job); } static inline void process_init(Job *job) { - pipe_process_init(job); + if (job->opts.pty) { + pty_process_init(job); + } else { + pipe_process_init(job); + } } static inline void process_close(Job *job) @@ -59,12 +64,20 @@ static inline void process_close(Job *job) return; } job->closed = true; - pipe_process_close(job); + if (job->opts.pty) { + pty_process_close(job); + } else { + pipe_process_close(job); + } } static inline void process_destroy(Job *job) { - pipe_process_destroy(job); + if (job->opts.pty) { + pty_process_destroy(job); + } else { + pipe_process_destroy(job); + } } static inline void job_exit_callback(Job *job) diff --git a/src/nvim/os/pty_process.c b/src/nvim/os/pty_process.c new file mode 100644 index 00000000000000..bd7247c741c09f --- /dev/null +++ b/src/nvim/os/pty_process.c @@ -0,0 +1,225 @@ +// Some of the code came from pangoterm and libuv +#include +#include +#include + +#include +#include +#include +#include +#include + +// forkpty is not in POSIX, so headers are platform-specific +#if defined(__FreeBSD__) +# include +#elif defined(__OpenBSD__) || defined(__NetBSD__) || defined(__APPLE__) +# include +#else +# include +#endif + +#include + +#include "nvim/os/job.h" +#include "nvim/os/job_defs.h" +#include "nvim/os/job_private.h" +#include "nvim/os/pty_process.h" +#include "nvim/memory.h" + +#ifdef INCLUDE_GENERATED_DECLARATIONS +# include "os/pty_process.c.generated.h" +#endif + +typedef struct { + struct winsize winsize; + uv_pipe_t proc_stdin, proc_stdout, proc_stderr; + uv_signal_t schld; + int tty_fd; +} PtyProcess; + +void pty_process_init(Job *job) +{ + PtyProcess *ptyproc = xmalloc(sizeof(PtyProcess)); + + if (job->opts.writable) { + uv_pipe_init(uv_default_loop(), &ptyproc->proc_stdin, 0); + ptyproc->proc_stdin.data = NULL; + } + + if (job->opts.stdout_cb) { + uv_pipe_init(uv_default_loop(), &ptyproc->proc_stdout, 0); + ptyproc->proc_stdout.data = NULL; + } + + if (job->opts.stderr_cb) { + uv_pipe_init(uv_default_loop(), &ptyproc->proc_stderr, 0); + ptyproc->proc_stderr.data = NULL; + } + + job->proc_stdin = (uv_stream_t *)&ptyproc->proc_stdin; + job->proc_stdout = (uv_stream_t *)&ptyproc->proc_stdout; + job->proc_stderr = (uv_stream_t *)&ptyproc->proc_stderr; + job->process = ptyproc; +} + +void pty_process_destroy(Job *job) +{ + free(job->opts.term_name); + free(job->process); + job->process = NULL; +} + +bool pty_process_spawn(Job *job) +{ + int master; + PtyProcess *ptyproc = job->process; + ptyproc->winsize = (struct winsize){job->opts.height, job->opts.width, 0, 0}; + struct termios termios; + init_termios(&termios); + uv_disable_stdio_inheritance(); + + int pid = forkpty(&master, NULL, &termios, &ptyproc->winsize); + + if (pid < 0) { + return false; + } else if (pid == 0) { + init_child(job); + abort(); + } + + // make sure the master file descriptor is non blocking + fcntl(master, F_SETFL, fcntl(master, F_GETFL) | O_NONBLOCK); + + if (job->opts.writable) { + uv_pipe_open(&ptyproc->proc_stdin, dup(master)); + } + + if (job->opts.stdout_cb) { + uv_pipe_open(&ptyproc->proc_stdout, dup(master)); + } + + if (job->opts.stderr_cb) { + uv_pipe_open(&ptyproc->proc_stderr, dup(master)); + } + + uv_signal_init(uv_default_loop(), &ptyproc->schld); + uv_signal_start(&ptyproc->schld, chld_handler, SIGCHLD); + ptyproc->schld.data = job; + ptyproc->tty_fd = master; + job->pid = pid; + return true; +} + +void pty_process_close(Job *job) +{ + PtyProcess *ptyproc = job->process; + uv_signal_stop(&ptyproc->schld); + uv_close((uv_handle_t *)&ptyproc->schld, NULL); + job_close_streams(job); + job_decref(job); +} + +void pty_process_resize(Job *job, uint16_t width, uint16_t height) +{ + PtyProcess *ptyproc = job->process; + ptyproc->winsize = (struct winsize){height, width, 0, 0}; + ioctl(ptyproc->tty_fd, TIOCSWINSZ, &ptyproc->winsize); +} + +static void init_child(Job *job) +{ + unsetenv("COLUMNS"); + unsetenv("LINES"); + unsetenv("TERMCAP"); + unsetenv("COLORTERM"); + unsetenv("COLORFGBG"); + + signal(SIGCHLD, SIG_DFL); + signal(SIGHUP, SIG_DFL); + signal(SIGINT, SIG_DFL); + signal(SIGQUIT, SIG_DFL); + signal(SIGTERM, SIG_DFL); + signal(SIGALRM, SIG_DFL); + + setenv("TERM", job->opts.term_name ? job->opts.term_name : "ansi", 1); + execvp(job->opts.argv[0], job->opts.argv); + fprintf(stderr, "execvp failed: %s\n", strerror(errno)); +} + +static void chld_handler(uv_signal_t *handle, int signum) +{ + Job *job = handle->data; + int stat = 0; + + if (waitpid(job->pid, &stat, 0) < 0) { + fprintf(stderr, "Waiting for pid %d failed: %s\n", job->pid, + strerror(errno)); + return; + } + + if (WIFSTOPPED(stat) || WIFCONTINUED(stat)) { + // Did not exit + return; + } + + if (WIFEXITED(stat)) { + job->status = WEXITSTATUS(stat); + } else if (WIFSIGNALED(stat)) { + job->status = WTERMSIG(stat); + } + + pty_process_close(job); +} + +static void init_termios(struct termios *termios) +{ + memset(termios, 0, sizeof(struct termios)); + // Taken from pangoterm + termios->c_iflag = ICRNL|IXON; + termios->c_oflag = OPOST|ONLCR|TAB0; + termios->c_cflag = CS8|CREAD; + termios->c_lflag = ISIG|ICANON|IEXTEN|ECHO|ECHOE|ECHOK; + + cfsetspeed(termios, 38400); + +#ifdef IUTF8 + termios->c_iflag |= IUTF8; +#endif +#ifdef NL0 + termios->c_oflag |= NL0; +#endif +#ifdef CR0 + termios->c_oflag |= CR0; +#endif +#ifdef BS0 + termios->c_oflag |= BS0; +#endif +#ifdef VT0 + termios->c_oflag |= VT0; +#endif +#ifdef FF0 + termios->c_oflag |= FF0; +#endif +#ifdef ECHOCTL + termios->c_lflag |= ECHOCTL; +#endif +#ifdef ECHOKE + termios->c_lflag |= ECHOKE; +#endif + + termios->c_cc[VINTR] = 0x1f & 'C'; + termios->c_cc[VQUIT] = 0x1f & '\\'; + termios->c_cc[VERASE] = 0x7f; + termios->c_cc[VKILL] = 0x1f & 'U'; + termios->c_cc[VEOF] = 0x1f & 'D'; + termios->c_cc[VEOL] = _POSIX_VDISABLE; + termios->c_cc[VEOL2] = _POSIX_VDISABLE; + termios->c_cc[VSTART] = 0x1f & 'Q'; + termios->c_cc[VSTOP] = 0x1f & 'S'; + termios->c_cc[VSUSP] = 0x1f & 'Z'; + termios->c_cc[VREPRINT] = 0x1f & 'R'; + termios->c_cc[VWERASE] = 0x1f & 'W'; + termios->c_cc[VLNEXT] = 0x1f & 'V'; + termios->c_cc[VMIN] = 1; + termios->c_cc[VTIME] = 0; +} diff --git a/src/nvim/os/pty_process.h b/src/nvim/os/pty_process.h new file mode 100644 index 00000000000000..62fcd1671fbec6 --- /dev/null +++ b/src/nvim/os/pty_process.h @@ -0,0 +1,7 @@ +#ifndef NVIM_OS_PTY_PROCESS_H +#define NVIM_OS_PTY_PROCESS_H + +#ifdef INCLUDE_GENERATED_DECLARATIONS +# include "os/pty_process.h.generated.h" +#endif +#endif // NVIM_OS_PTY_PROCESS_H diff --git a/test/functional/job/CMakeLists.txt b/test/functional/job/CMakeLists.txt new file mode 100644 index 00000000000000..14ec287816d0ed --- /dev/null +++ b/test/functional/job/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(tty-test tty-test.c) +target_link_libraries(tty-test ${LIBUV_LIBRARIES}) diff --git a/test/functional/job/job_spec.lua b/test/functional/job/job_spec.lua index b87c3a827ba22a..0051de09e106b4 100644 --- a/test/functional/job/job_spec.lua +++ b/test/functional/job/job_spec.lua @@ -4,6 +4,7 @@ local clear, nvim, eq, neq, ok, expect, eval, next_message, run, stop, session = helpers.clear, helpers.nvim, helpers.eq, helpers.neq, helpers.ok, helpers.expect, helpers.eval, helpers.next_message, helpers.run, helpers.stop, helpers.session +local insert = helpers.insert local channel = nvim('get_api_info')[1] @@ -124,4 +125,57 @@ describe('jobs', function() eq({'notification', 'j', {{jobid, 'stdout', {'abcdef'}}}}, next_message()) eq({'notification', 'j', {{jobid, 'exit'}}}, next_message()) end) + + describe('running tty-test program', function() + local function next_chunk() + local rv = '' + while true do + local msg = next_message() + local data = msg[3][1] + for i = 1, #data do + data[i] = data[i]:gsub('\n', '\000') + end + rv = table.concat(data, '\n') + rv = rv:gsub('\r\n$', '') + if rv ~= '' then + break + end + end + return rv + end + + local function send(str) + nvim('command', 'call jobsend(j, "'..str..'")') + end + + before_each(function() + -- the full path to tty-test seems to be required when running on travis. + insert('build/bin/tty-test') + nvim('command', 'let exec = expand(":p")') + nvim('command', notify_str('v:job_data[1]', 'get(v:job_data, 2)')) + nvim('command', "let j = jobstart('xxx', exec, [], {})") + eq('tty ready', next_chunk()) + end) + + it('echoing input', function() + send('test') + -- the tty driver will echo input by default + eq('test', next_chunk()) + end) + + it('resizing window', function() + nvim('command', 'call jobresize(j, 40, 10)') + eq('screen resized. rows: 10, columns: 40', next_chunk()) + nvim('command', 'call jobresize(j, 10, 40)') + eq('screen resized. rows: 40, columns: 10', next_chunk()) + end) + + it('preprocessing ctrl+c with terminal driver', function() + send('\\') + eq('^Cinterrupt received, press again to exit', next_chunk()) + send('\\') + eq('^Ctty done', next_chunk()) + eq({'notification', 'exit', {0}}, next_message()) + end) + end) end) diff --git a/test/functional/job/tty-test.c b/test/functional/job/tty-test.c new file mode 100644 index 00000000000000..25e76840aaf67e --- /dev/null +++ b/test/functional/job/tty-test.c @@ -0,0 +1,114 @@ +#include +#include +#include +#include + +#ifdef _WIN32 +#include +bool owns_tty(void) +{ + HWND consoleWnd = GetConsoleWindow(); + DWORD dwProcessId; + GetWindowThreadProcessId(consoleWnd, &dwProcessId); + return GetCurrentProcessId() == dwProcessId; +} +#else +bool owns_tty(void) +{ + // TODO: Check if the process is the session leader + return true; +} +#endif + +#define is_terminal(stream) (uv_guess_handle(fileno(stream)) == UV_TTY) +#define BUF_SIZE 0xfff + +static void walk_cb(uv_handle_t *handle, void *arg) { + if (!uv_is_closing(handle)) { + uv_close(handle, NULL); + } +} + +static void sigwinch_cb(uv_signal_t *handle, int signum) +{ + int width, height; + uv_tty_t *tty = handle->data; + uv_tty_get_winsize(tty, &width, &height); + fprintf(stderr, "screen resized. rows: %d, columns: %d\n", height, width); +} + +static void sigint_cb(uv_signal_t *handle, int signum) +{ + bool *interrupted = handle->data; + + if (*interrupted) { + uv_walk(uv_default_loop(), walk_cb, NULL); + return; + } + + *interrupted = true; + fprintf(stderr, "interrupt received, press again to exit\n"); +} + +static void alloc_cb(uv_handle_t *handle, size_t suggested, uv_buf_t *buf) +{ + buf->len = BUF_SIZE; + buf->base = malloc(BUF_SIZE); +} + +static void read_cb(uv_stream_t *stream, ssize_t cnt, const uv_buf_t *buf) +{ + if (cnt <= 0) { + uv_read_stop(stream); + return; + } + + fprintf(stderr, "received data: "); + uv_loop_t write_loop; + uv_loop_init(&write_loop); + uv_tty_t out; + uv_tty_init(&write_loop, &out, 1, 0); + uv_write_t req; + uv_buf_t b = {.base = buf->base, .len = buf->len}; + uv_write(&req, (uv_stream_t *)&out, &b, 1, NULL); + uv_run(&write_loop, UV_RUN_DEFAULT); + uv_close((uv_handle_t *)&out, NULL); + uv_run(&write_loop, UV_RUN_DEFAULT); + if (uv_loop_close(&write_loop)) { + abort(); + } + free(buf->base); +} + +int main(int argc, char **argv) +{ + if (!is_terminal(stdin)) { + fprintf(stderr, "stdin is not a terminal\n"); + exit(2); + } + + if (!is_terminal(stdout)) { + fprintf(stderr, "stdout is not a terminal\n"); + exit(2); + } + + if (!is_terminal(stderr)) { + fprintf(stderr, "stderr is not a terminal\n"); + exit(2); + } + + bool interrupted = false; + fprintf(stderr, "tty ready\n"); + uv_tty_t tty; + uv_tty_init(uv_default_loop(), &tty, fileno(stderr), 1); + uv_read_start((uv_stream_t *)&tty, alloc_cb, read_cb); + uv_signal_t sigwinch_watcher, sigint_watcher; + uv_signal_init(uv_default_loop(), &sigwinch_watcher); + sigwinch_watcher.data = &tty; + uv_signal_start(&sigwinch_watcher, sigwinch_cb, SIGWINCH); + uv_signal_init(uv_default_loop(), &sigint_watcher); + sigint_watcher.data = &interrupted; + uv_signal_start(&sigint_watcher, sigint_cb, SIGINT); + uv_run(uv_default_loop(), UV_RUN_DEFAULT); + fprintf(stderr, "tty done\n"); +}