diff --git a/bootstrap/lib/kernel/ebin/group.beam b/bootstrap/lib/kernel/ebin/group.beam index dcba3c61415b..26ba0ebe5942 100644 Binary files a/bootstrap/lib/kernel/ebin/group.beam and b/bootstrap/lib/kernel/ebin/group.beam differ diff --git a/bootstrap/lib/kernel/ebin/prim_tty.beam b/bootstrap/lib/kernel/ebin/prim_tty.beam index 724616684b8a..9804b8f54979 100644 Binary files a/bootstrap/lib/kernel/ebin/prim_tty.beam and b/bootstrap/lib/kernel/ebin/prim_tty.beam differ diff --git a/bootstrap/lib/kernel/ebin/user_drv.beam b/bootstrap/lib/kernel/ebin/user_drv.beam index cc7c72ac9e67..d8ad75b57e0c 100644 Binary files a/bootstrap/lib/kernel/ebin/user_drv.beam and b/bootstrap/lib/kernel/ebin/user_drv.beam differ diff --git a/bootstrap/lib/kernel/ebin/user_sup.beam b/bootstrap/lib/kernel/ebin/user_sup.beam index 8235b0e0e205..7b35ff2a247f 100644 Binary files a/bootstrap/lib/kernel/ebin/user_sup.beam and b/bootstrap/lib/kernel/ebin/user_sup.beam differ diff --git a/bootstrap/lib/stdlib/ebin/io_lib.beam b/bootstrap/lib/stdlib/ebin/io_lib.beam index 1f443d22f601..e03a42d6f1c9 100644 Binary files a/bootstrap/lib/stdlib/ebin/io_lib.beam and b/bootstrap/lib/stdlib/ebin/io_lib.beam differ diff --git a/bootstrap/lib/stdlib/ebin/shell.beam b/bootstrap/lib/stdlib/ebin/shell.beam index eafb537cd6ae..67e6fe0cb794 100644 Binary files a/bootstrap/lib/stdlib/ebin/shell.beam and b/bootstrap/lib/stdlib/ebin/shell.beam differ diff --git a/erts/emulator/nifs/common/prim_tty_nif.c b/erts/emulator/nifs/common/prim_tty_nif.c index 825677d56364..b3a3b8b0fb27 100644 --- a/erts/emulator/nifs/common/prim_tty_nif.c +++ b/erts/emulator/nifs/common/prim_tty_nif.c @@ -60,6 +60,9 @@ #ifdef HAVE_SYS_UIO_H #include #endif +#ifdef HAVE_POLL_H +#include +#endif #if defined IOV_MAX #define MAXIOV IOV_MAX @@ -82,26 +85,28 @@ #define DEF_HEIGHT 24 #define DEF_WIDTH 80 +enum TTYState { + unavailable, + disabled, + enabled +}; + typedef struct { #ifdef __WIN32__ HANDLE ofd; HANDLE ifd; - HANDLE ifdOverlapped; DWORD dwOriginalOutMode; DWORD dwOriginalInMode; DWORD dwOutMode; DWORD dwInMode; - /* Fields to handle the threaded reader */ - OVERLAPPED overlapped; - ErlNifBinary overlappedBuffer; #else int ofd; /* stdout */ int ifd; /* stdin */ #endif ErlNifPid self; ErlNifPid reader; - int tty; /* if the tty is initialized */ + enum TTYState tty; /* if the tty is initialized */ #ifdef HAVE_TERMCAP struct termios tty_smode; struct termios tty_rmode; @@ -123,7 +128,7 @@ static ErlNifResourceType *tty_rt; static ERL_NIF_TERM isatty_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); static ERL_NIF_TERM tty_create_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); static ERL_NIF_TERM tty_init_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); -static ERL_NIF_TERM tty_set_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); +static ERL_NIF_TERM tty_is_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); static ERL_NIF_TERM setlocale_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); static ERL_NIF_TERM tty_select_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); static ERL_NIF_TERM tty_write_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]); @@ -143,14 +148,14 @@ static ERL_NIF_TERM tty_tgoto_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM a static ErlNifFunc nif_funcs[] = { {"isatty", 1, isatty_nif}, {"tty_create", 0, tty_create_nif}, - {"tty_init", 3, tty_init_nif}, - {"tty_set", 1, tty_set_nif}, + {"tty_init", 2, tty_init_nif}, {"setlocale", 1, setlocale_nif}, + {"tty_is_open", 2, tty_is_open}, {"tty_select", 2, tty_select_nif}, {"tty_window_size", 1, tty_window_size_nif}, {"write_nif", 2, tty_write_nif, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"tty_encoding", 1, tty_encoding_nif}, - {"read_nif", 2, tty_read_nif, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"read_nif", 3, tty_read_nif, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"isprint", 1, isprint_nif}, {"wcwidth", 1, wcwidth_nif}, {"wcswidth", 1, wcswidth_nif}, @@ -186,6 +191,7 @@ ERL_NIF_INIT(prim_tty, nif_funcs, load, NULL, upgrade, unload) ATOM_DECL(stdout); \ ATOM_DECL(stderr); \ ATOM_DECL(select); \ + ATOM_DECL(raw); \ ATOM_DECL(sig); @@ -228,18 +234,48 @@ static int tty_get_fd(ErlNifEnv *env, ERL_NIF_TERM atom, int *fd) { return 1; } -static ERL_NIF_TERM isatty_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +#ifdef __WIN32__ +static HANDLE tty_get_handle(ErlNifEnv *env, ERL_NIF_TERM atom) { + HANDLE handle = INVALID_HANDLE_VALUE; int fd; + if (tty_get_fd(env, atom, &fd)) { + + switch (fd) { + case 0: handle = GetStdHandle(STD_INPUT_HANDLE); break; + case 1: handle = GetStdHandle(STD_OUTPUT_HANDLE); break; + case 2: handle = GetStdHandle(STD_ERROR_HANDLE); break; + } + } + return handle; +} +#endif +static ERL_NIF_TERM isatty_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +#ifdef __WIN32__ + HANDLE handle = tty_get_handle(env, argv[0]); + + if (handle == INVALID_HANDLE_VALUE) + return atom_ebadf; + + switch (GetFileType(handle)) { + case FILE_TYPE_CHAR: return atom_true; + case FILE_TYPE_PIPE: + case FILE_TYPE_DISK: return atom_false; + default: return atom_ebadf; + } +#else + int fd; if (tty_get_fd(env, argv[0], &fd)) { if (isatty(fd)) { return atom_true; } else if (errno == EINVAL || errno == ENOTTY) { return atom_false; - } else { + } + else { return atom_ebadf; } } +#endif return enif_make_badarg(env); } @@ -249,7 +285,7 @@ static ERL_NIF_TERM tty_encoding_nif(ErlNifEnv* env, int argc, const ERL_NIF_TER TTYResource *tty; if (!enif_get_resource(env, argv[0], tty_rt, (void **)&tty)) return enif_make_badarg(env); - if (tty->tty) + if (GetFileType(tty->ifd) == FILE_TYPE_CHAR) return enif_make_tuple2(env, enif_make_atom(env, "utf16"), enif_make_atom(env, "little")); #endif @@ -390,6 +426,7 @@ static ERL_NIF_TERM tty_read_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar TTYResource *tty; ErlNifBinary bin; ERL_NIF_TERM res_term; + Uint64 n; ssize_t res = 0; #ifdef __WIN32__ HANDLE select_event; @@ -397,37 +434,38 @@ static ERL_NIF_TERM tty_read_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar int select_event; #endif + ASSERT(argc == 3); + if (!enif_get_resource(env, argv[0], tty_rt, (void **)&tty)) return enif_make_badarg(env); + if (!enif_get_uint64(env, argv[2], &n)) + return enif_make_badarg(env); + + n = n > 1024 ? 1024 : n; + select_event = tty->ifd; -#ifdef __WIN32__ debug("tty_read_nif(%T, %T, %T)\r\n",argv[0],argv[1],argv[2]); + +#ifdef __WIN32__ /** * We have three different read scenarios we need to deal with * using different approaches. * - * ### New Shell + * ### New Shell / Raw NoShell * * Here characters need to be delivered as they are typed and we * also need to handle terminal resize events. So we use ReadConsoleInputW * to read. * - * ### Input is a terminal, but there is no shell, or old shell + * ### Input is a terminal, but there is noshell, or old shell * * Here we should operate in "line mode", that is characters should only - * be delivered when the user hits enter. Therefore we cannot use - * ReadConsoleInputW, and we also cannot use ReadFile in synchronous mode - * as it will block until a complete line is done. So we use the - * OVERLAPPED support of ReadFile to read data. - * - * From this mode it is important to be able to upgrade to a "New Shell" - * terminal. - * - * Unfortunately it does not seem like unicode works at all when in this - * mode. At least when I try it, all unicode characters are translated to - * "?". Maybe it could be solved by using ReadConsoleW? + * be delivered when the user hits enter. Here we used to use OVERLAPPED ReadFile, + * but that caused unicode to not work, so instead we use ReadFile. + * + * This call will block a single dirty io schedulers until the user hits Enter. * * ### Input is an anonymous pipe * @@ -436,172 +474,157 @@ static ERL_NIF_TERM tty_read_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar * call will not block until a full line is complete, so this is safe to do. * **/ - if (GetFileType(tty->ifd) == FILE_TYPE_CHAR) { - if (tty->ifdOverlapped == INVALID_HANDLE_VALUE) { - /* Input is a terminal and we are in "new shell" mode */ + if (GetFileType(tty->ifd) == FILE_TYPE_CHAR && tty->tty == enabled) { + /* Input is a terminal and we are in "new shell"/"raw" mode */ - ssize_t inputs_read, num_characters = 0; - wchar_t *characters = NULL; - INPUT_RECORD inputs[128]; + ssize_t inputs_read, num_characters = 0; + wchar_t *characters = NULL; + INPUT_RECORD inputs[128]; - ASSERT(tty->tty); + n = MIN(n, sizeof(inputs) / sizeof(inputs[0])); - if (!ReadConsoleInputW(tty->ifd, inputs, sizeof(inputs)/sizeof(*inputs), - &inputs_read)) { - return make_errno_error(env, "ReadConsoleInput"); - } + ASSERT(tty->tty == enabled); + + if (!ReadConsoleInputW(tty->ifd, inputs, n, &inputs_read)) { + return make_errno_error(env, "ReadConsoleInput"); + } - /** - * Reading keyevents using ReadConsoleInput is a bit fragile as - * different consoles and different input modes cause events to - * be triggered in different ways. I've so far identified four - * different input methods that work slightly differently and - * two classes of consoles that also work slightly differently. - * - * The input methods are: - * - Normal key presses - * - Microsoft IME - * - Pasting into console - * - Using Alt+ modifiers - * - * ### Normal key presses - * - * When typing normally both key down and up events are sent with - * the typed character. If typing a Unicode character (for instance if - * you are using a keyboard with Cyrillic layout), that character also - * is sent as both key up and key down. This behavior is the same on all - * consoles. - * - * ### Microsoft IME - * - * When typing Japanese, Chinese and many other languages it is common to - * use a "Input Method Editor". Basically what it does is that if you type - * "sushi" using the Japanese IME it convert that to "すし". All characters - * typed using IME end up as only keydown events on cmd.exe and powershell, - * while in Windows Terminal and Alacritty both keydown and keyup events - * are sent. - * - * ### Pasting into console - * - * When text pasting into the console, any ascii text pasted ends up as both - * keydown and keyup events. Any non-ascii text pasted seem to be sent using - * a keydown event with UnicodeChar set to 0 and then immediately followed by a - * keyup event with the non-ascii text. - * - * ### Using Alt+ modifiers - * - * A very old way of inputting Unicode characters on Windows is to press - * the left alt key and then some numbers on the number pad. For instance - * you can type Alt+1 to write a ☺. When doing this first a keydown - * with 0 is sent and then some events later a keyup with the character - * is sent. This behavior seems to only work on cmd.exe and powershell. - * - * - * So to summarize: - * - Normal presses -- Always keydown and keyup events - * - IME -- Always keydown, sometimes keyup - * - Pasting -- Always keydown=0 directly followed by keyup=value - * - Alt+ -- Sometimes keydown=0 followed eventually by keyup=value - * - * So in order to read characters we should always read the keydown event, - * except when it is 0, then we should read the adjacent keyup event. - * This covers all modes and consoles except Alt+. If we want Alt+ to work - * we probably have to use PeekConsoleInput to make sure the correct events - * are available and inspect the state of the key event somehow. - **/ - - for (int i = 0; i < inputs_read; i++) { - if (inputs[i].EventType == KEY_EVENT) { - if (inputs[i].Event.KeyEvent.bKeyDown) { - if (inputs[i].Event.KeyEvent.uChar.UnicodeChar != 0) { - num_characters++; - } else if (i + 1 < inputs_read && !inputs[i+1].Event.KeyEvent.bKeyDown) { - num_characters++; - } + /** + * Reading keyevents using ReadConsoleInput is a bit fragile as + * different consoles and different input modes cause events to + * be triggered in different ways. I've so far identified four + * different input methods that work slightly differently and + * two classes of consoles that also work slightly differently. + * + * The input methods are: + * - Normal key presses + * - Microsoft IME + * - Pasting into console + * - Using Alt+ modifiers + * + * ### Normal key presses + * + * When typing normally both key down and up events are sent with + * the typed character. If typing a Unicode character (for instance if + * you are using a keyboard with Cyrillic layout), that character also + * is sent as both key up and key down. This behavior is the same on all + * consoles. + * + * ### Microsoft IME + * + * When typing Japanese, Chinese and many other languages it is common to + * use a "Input Method Editor". Basically what it does is that if you type + * "sushi" using the Japanese IME it convert that to "すし". All characters + * typed using IME end up as only keydown events on cmd.exe and powershell, + * while in Windows Terminal and Alacritty both keydown and keyup events + * are sent. + * + * ### Pasting into console + * + * When text pasting into the console, any ascii text pasted ends up as both + * keydown and keyup events. Any non-ascii text pasted seem to be sent using + * a keydown event with UnicodeChar set to 0 and then immediately followed by a + * keyup event with the non-ascii text. + * + * ### Using Alt+ modifiers + * + * A very old way of inputting Unicode characters on Windows is to press + * the left alt key and then some numbers on the number pad. For instance + * you can type Alt+1 to write a ☺. When doing this first a keydown + * with 0 is sent and then some events later a keyup with the character + * is sent. This behavior seems to only work on cmd.exe and powershell. + * + * + * So to summarize: + * - Normal presses -- Always keydown and keyup events + * - IME -- Always keydown, sometimes keyup + * - Pasting -- Always keydown=0 directly followed by keyup=value + * - Alt+ -- Sometimes keydown=0 followed eventually by keyup=value + * + * So in order to read characters we should always read the keydown event, + * except when it is 0, then we should read the adjacent keyup event. + * This covers all modes and consoles except Alt+. If we want Alt+ to work + * we probably have to use PeekConsoleInput to make sure the correct events + * are available and inspect the state of the key event somehow. + **/ + + for (int i = 0; i < inputs_read; i++) { + if (inputs[i].EventType == KEY_EVENT) { + if (inputs[i].Event.KeyEvent.bKeyDown) { + if (inputs[i].Event.KeyEvent.uChar.UnicodeChar != 0) { + num_characters++; + } else if (i + 1 < inputs_read && !inputs[i+1].Event.KeyEvent.bKeyDown) { + num_characters++; } } } - enif_alloc_binary(num_characters * sizeof(wchar_t), &bin); - characters = (wchar_t*)bin.data; - for (int i = 0; i < inputs_read; i++) { - switch (inputs[i].EventType) - { - case KEY_EVENT: - if (inputs[i].Event.KeyEvent.bKeyDown) { - if (inputs[i].Event.KeyEvent.uChar.UnicodeChar != 0) { - debug("Read %u\r\n",inputs[i].Event.KeyEvent.uChar.UnicodeChar); - characters[res++] = inputs[i].Event.KeyEvent.uChar.UnicodeChar; - } else if (i + 1 < inputs_read && !inputs[i+1].Event.KeyEvent.bKeyDown) { - debug("Read %u\r\n",inputs[i+1].Event.KeyEvent.uChar.UnicodeChar); - characters[res++] = inputs[i+1].Event.KeyEvent.uChar.UnicodeChar; - } + } + enif_alloc_binary(num_characters * sizeof(wchar_t), &bin); + characters = (wchar_t*)bin.data; + for (int i = 0; i < inputs_read; i++) { + switch (inputs[i].EventType) + { + case KEY_EVENT: + if (inputs[i].Event.KeyEvent.bKeyDown) { + if (inputs[i].Event.KeyEvent.uChar.UnicodeChar != 0) { + debug("Read %u\r\n",inputs[i].Event.KeyEvent.uChar.UnicodeChar); + characters[res++] = inputs[i].Event.KeyEvent.uChar.UnicodeChar; + } else if (i + 1 < inputs_read && !inputs[i+1].Event.KeyEvent.bKeyDown) { + debug("Read %u\r\n",inputs[i+1].Event.KeyEvent.uChar.UnicodeChar); + characters[res++] = inputs[i+1].Event.KeyEvent.uChar.UnicodeChar; } - break; - case WINDOW_BUFFER_SIZE_EVENT: - enif_send(env, &tty->self, NULL, - enif_make_tuple2(env, argv[1], - enif_make_tuple2( - env, enif_make_atom(env, "signal"), - enif_make_atom(env, "resize")))); - break; - case MOUSE_EVENT: - /* We don't do anything with the mouse event */ - break; - case MENU_EVENT: - case FOCUS_EVENT: - /* Should be ignored according to - https://docs.microsoft.com/en-us/windows/console/input-record-str */ - break; - default: - fprintf(stderr,"Unknown event: %d\r\n", inputs[i].EventType); - break; } + break; + case WINDOW_BUFFER_SIZE_EVENT: + enif_send(env, &tty->self, NULL, + enif_make_tuple2(env, argv[1], + enif_make_tuple2(env, + enif_make_atom(env, "signal"), + enif_make_atom(env, "resize")))); + break; + case MOUSE_EVENT: + /* We don't do anything with the mouse event */ + break; + case MENU_EVENT: + case FOCUS_EVENT: + /* Should be ignored according to + https://docs.microsoft.com/en-us/windows/console/input-record-str */ + break; + default: + fprintf(stderr,"Unknown event: %d\r\n", inputs[i].EventType); + break; } - res *= sizeof(wchar_t); - } else { - /* Input is a terminal and we are in "noshell" or "oldshell" mode */ - DWORD bytesRead = 0; - debug("GetOverlapped on %d\r\n", tty->ifdOverlapped); - if (!GetOverlappedResult(tty->ifdOverlapped, &tty->overlapped, &bytesRead, TRUE)) { - if (GetLastError() == ERROR_OPERATION_ABORTED && tty->tty) { - /* The overlapped operation was cancels by CancelIo because - we are upgrading to "newshell". So we close the handles - involved with the overlapped io and select on the stdin - handle. From now on we use ReadConsoleInputW to get - input. */ - CloseHandle(tty->ifdOverlapped); - CloseHandle(tty->overlapped.hEvent); - tty->ifdOverlapped = INVALID_HANDLE_VALUE; - enif_select(env, tty->ifd, ERL_NIF_SELECT_READ, tty, NULL, argv[1]); - /* Return {error,aborted} to signal that the encoding has changed . */ - return make_error(env, enif_make_atom(env, "aborted")); - } - return make_errno_error(env, "GetOverlappedResult"); - } - if (bytesRead == 0) { - return make_error(env, enif_make_atom(env, "closed")); - } - debug("Read %d bytes\r\n", bytesRead); -#ifdef HARD_DEBUG - for (int i = 0; i < bytesRead; i++) - debug("Read %u\r\n", tty->overlappedBuffer.data[i]); -#endif - bin = tty->overlappedBuffer; - res = bytesRead; - enif_alloc_binary(1024, &tty->overlappedBuffer); - if (!ReadFile(tty->ifdOverlapped, tty->overlappedBuffer.data, - tty->overlappedBuffer.size, NULL, &tty->overlapped)) { - if (GetLastError() != ERROR_IO_PENDING) - return make_errno_error(env, "ReadFile"); - } - select_event = tty->overlapped.hEvent; } + res *= sizeof(wchar_t); } else { - /* Input is not a terminal */ + /* Input is not a terminal or we are in "cooked" mode */ DWORD bytesTransferred; - enif_alloc_binary(1024, &bin); - if (ReadFile(tty->ifd, bin.data, bin.size, - &bytesTransferred, NULL)) { + BOOL readRes; + const char *errorFunction; + if (GetFileType(tty->ifd) == FILE_TYPE_CHAR) { + /* This ReadConsoleW call may hang until Enter is pressed. + * This will block one dirty io schedulers, but that should be ok as it will + * only happen if the application wants to read data, i.e. it is reasonable to + * expect and enter to be hit "soon". + * + * NOTE: I've tried various things to try to figure out if ReadFile/ReadConsole + * will block or not, but one of the crazy thing with ReadFile/ReadConsole is + * that you need to call it before the characters on the terminal are echoed, + * so we need this call to be blocking. What we could do is move the read to another + * thread so that we don't consume a dirty io scheduler, but I've opted to keep + * it simple and not do that. + */ + enif_alloc_binary(n * sizeof(wchar_t), &bin); + readRes = ReadConsoleW(tty->ifd, bin.data, n, &bytesTransferred, NULL); + bytesTransferred *= sizeof(wchar_t); + errorFunction = "ReadConsoleW"; + } + else { + enif_alloc_binary(n, &bin); + readRes = ReadFile(tty->ifd, bin.data, bin.size, &bytesTransferred, NULL); + errorFunction = "ReadFile"; + } + if (readRes) { res = bytesTransferred; if (res == 0) { enif_release_binary(&bin); @@ -612,11 +635,11 @@ static ERL_NIF_TERM tty_read_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar enif_release_binary(&bin); if (error == ERROR_BROKEN_PIPE) return make_error(env, enif_make_atom(env, "closed")); - return make_errno_error(env, "ReadFile"); + return make_errno_error(env, errorFunction); } } #else - enif_alloc_binary(1024, &bin); + enif_alloc_binary(n, &bin); res = read(tty->ifd, bin.data, bin.size); if (res < 0) { if (errno != EAGAIN && errno != EINTR) { @@ -647,6 +670,76 @@ static ERL_NIF_TERM tty_read_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar return enif_make_tuple2(env, atom_ok, res_term); } +/* Poll if stdin/stdout/stderr are still open. */ +static ERL_NIF_TERM tty_is_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { + TTYResource *tty; +#ifdef __WIN32__ + HANDLE handle; +#else + int fd; +#endif + + if (!enif_get_resource(env, argv[0], tty_rt, (void **)&tty)) + return enif_make_badarg(env); + +#ifdef __WIN32__ + + handle = tty_get_handle(env, argv[1]); + + if (handle != INVALID_HANDLE_VALUE) { + DWORD bytesAvailable = 0; + + switch (GetFileType(handle)) { + case FILE_TYPE_CHAR: { + DWORD eventsAvailable; + if (!GetNumberOfConsoleInputEvents(handle, &eventsAvailable)) { + return atom_false; + } + return atom_true; + } + case FILE_TYPE_DISK: { + return atom_true; + } + default: { + DWORD bytesAvailable = 0; + // Check the state of the pipe + if (!PeekNamedPipe(handle, NULL, 0, NULL, &bytesAvailable, NULL)) { + DWORD err = GetLastError(); + + // If the error is ERROR_BROKEN_PIPE, it means stdin has been closed + if (err == ERROR_BROKEN_PIPE) { + return atom_false; + } + else { + return make_errno_error(env, "PeekNamedPipe"); + } + } + return atom_true; + } + } + } +#else + if (tty_get_fd(env, argv[1], &fd)) { + struct pollfd fds[1]; + int ret; + + fds[0].fd = fd; + fds[0].events = POLLHUP; + fds[0].revents = 0; + ret = poll(fds, 1, 0); + + if (ret < 0) { + return make_errno_error(env, __FUNCTION__); + } else if (ret == 0) { + return atom_true; + } else if (ret == 1 && fds[0].revents & POLLHUP) { + return atom_false; + } + } +#endif + return enif_make_badarg(env); +} + static ERL_NIF_TERM setlocale_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { #ifdef __WIN32__ TTYResource *tty; @@ -782,13 +875,25 @@ static ERL_NIF_TERM tty_create_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM TTYResource *tty = enif_alloc_resource(tty_rt, sizeof(TTYResource)); ERL_NIF_TERM tty_term; memset(tty, 0, sizeof(*tty)); + +#ifdef HARD_DEBUG + logFile = fopen("tty.log","w+"); +#endif + + tty->tty = unavailable; + #ifndef __WIN32__ tty->ifd = 0; tty->ofd = 1; -#else -#ifdef HARD_DEBUG - logFile = fopen("tty.log","w+"); + +#ifdef HAVE_TERMCAP + if (tcgetattr(tty->ofd, &tty->tty_rmode) >= 0) { + tty->tty = disabled; + } #endif + +#else + tty->ifd = GetStdHandle(STD_INPUT_HANDLE); if (tty->ifd == INVALID_HANDLE_VALUE || tty->ifd == NULL) { tty->ifd = CreateFile("nul", GENERIC_READ, 0, @@ -806,6 +911,7 @@ static ERL_NIF_TERM tty_create_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM /* Failed to set any VT mode, can't do anything here. */ return make_errno_error(env, "SetConsoleMode"); } + tty->tty = disabled; } if (GetConsoleMode(tty->ifd, &tty->dwOriginalInMode)) { @@ -815,7 +921,7 @@ static ERL_NIF_TERM tty_create_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM return make_errno_error(env, "SetConsoleMode"); } } - tty->ifdOverlapped = INVALID_HANDLE_VALUE; + #endif tty_term = enif_make_resource(env, tty); @@ -830,50 +936,41 @@ static ERL_NIF_TERM tty_create_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM static ERL_NIF_TERM tty_init_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { #if defined(HAVE_TERMCAP) || defined(__WIN32__) - ERL_NIF_TERM canon, echo, sig; + ERL_NIF_TERM input; TTYResource *tty; - int fd; - debug("tty_init_nif(%T,%T,%T)\r\n", argv[0], argv[1], argv[2]); + debug("tty_init_nif(%T,%T)\r\n", argv[0], argv[1]); - if (argc != 3 || - !tty_get_fd(env, argv[1], &fd) || - !enif_is_map(env, argv[2])) { + if (argc != 2 || !enif_is_map(env, argv[1])) { return enif_make_badarg(env); } if (!enif_get_resource(env, argv[0], tty_rt, (void **)&tty)) return enif_make_badarg(env); - if (!enif_get_map_value(env, argv[2], enif_make_atom(env,"canon"), &canon)) - canon = enif_make_atom(env, "undefined"); - if (!enif_get_map_value(env, argv[2], enif_make_atom(env,"echo"), &echo)) - echo = enif_make_atom(env, "undefined"); - if (!enif_get_map_value(env, argv[2], enif_make_atom(env,"sig"), &sig)) - sig = enif_make_atom(env, "undefined"); + if (!enif_get_map_value(env, argv[1], enif_make_atom(env, "input"), &input)) + return enif_make_badarg(env); -#ifndef __WIN32__ - if (tcgetattr(fd, &tty->tty_rmode) < 0) { - return make_errno_error(env, "tcgetattr"); + if (tty->tty == unavailable) { + if (enif_is_identical(input, atom_raw)) + return make_enotsup(env); + return atom_ok; } + tty->tty = enif_is_identical(input, atom_raw) ? enabled : disabled; + +#ifndef __WIN32__ + + /* Restore the original mode, that is canonical echo mode */ tty->tty_smode = tty->tty_rmode; - /* Default characteristics for all usage including termcap output. */ - tty->tty_smode.c_iflag &= ~ISTRIP; + if (tty->tty == enabled) { - /* erts_fprintf(stderr,"canon %T\r\n", canon); */ - /* Turn canonical (line mode) on off. */ - if (enif_is_identical(canon, atom_true)) { - tty->tty_smode.c_iflag |= ICRNL; - tty->tty_smode.c_lflag |= ICANON; - tty->tty_smode.c_oflag |= OPOST; - tty->tty_smode.c_cc[VEOF] = tty->tty_rmode.c_cc[VEOF]; -#ifdef VDSUSP - tty->tty_smode.c_cc[VDSUSP] = tty->tty_rmode.c_cc[VDSUSP]; -#endif - } - if (enif_is_identical(canon, atom_false)) { + /* Default characteristics for all usage including termcap output. */ + tty->tty_smode.c_iflag &= ~ISTRIP; + + /* erts_fprintf(stderr,"canon %T\r\n", canon); */ + /* Turn canonical (line mode) off. */ tty->tty_smode.c_iflag &= ~ICRNL; tty->tty_smode.c_lflag &= ~ICANON; tty->tty_smode.c_oflag &= ~OPOST; @@ -883,83 +980,46 @@ static ERL_NIF_TERM tty_init_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar #ifdef VDSUSP tty->tty_smode.c_cc[VDSUSP] = 0; #endif - } - /* Turn echo on or off. */ - /* erts_fprintf(stderr,"echo %T\r\n", echo); */ - if (enif_is_identical(echo, atom_true)) - tty->tty_smode.c_lflag |= ECHO; - if (enif_is_identical(echo, atom_false)) + /* Turn echo off. */ + /* erts_fprintf(stderr,"echo %T\r\n", echo); */ tty->tty_smode.c_lflag &= ~ECHO; - /* erts_fprintf(stderr,"sig %T\r\n", sig); */ - /* Set extra characteristics for "RAW" mode, no signals. */ - if (enif_is_identical(sig, atom_true)) { - /* Ignore IMAXBEL as not POSIX. */ -#ifndef QNX - tty->tty_smode.c_iflag |= (BRKINT|IGNPAR|ICRNL|IXON|IXANY); -#else - tty->tty_smode.c_iflag |= (BRKINT|IGNPAR|ICRNL|IXON); -#endif - tty->tty_smode.c_lflag |= (ISIG|IEXTEN); } - if (enif_is_identical(sig, atom_false)) { - /* Ignore IMAXBEL as not POSIX. */ -#ifndef QNX - tty->tty_smode.c_iflag &= ~(BRKINT|IGNPAR|ICRNL|IXON|IXANY); -#else - tty->tty_smode.c_iflag &= ~(BRKINT|IGNPAR|ICRNL|IXON); -#endif - tty->tty_smode.c_lflag &= ~(ISIG|IEXTEN); + + if (tcsetattr(tty->ofd, TCSANOW, &tty->tty_smode) < 0) { + return make_errno_error(env, "tcsetattr"); } #else + DWORD dwOutMode = tty->dwOutMode; + DWORD dwInMode = tty->dwInMode; + debug("origOutMode: %x origInMode: %x\r\n", tty->dwOriginalOutMode, tty->dwOriginalInMode); - /* If we cannot disable NEWLINE_AUTO_RETURN we continue anyway as things work */ - if (SetConsoleMode(tty->ofd, tty->dwOutMode | DISABLE_NEWLINE_AUTO_RETURN)) { - tty->dwOutMode |= DISABLE_NEWLINE_AUTO_RETURN; + if (tty->tty == enabled) { + dwOutMode |= DISABLE_NEWLINE_AUTO_RETURN; + dwInMode &= ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT); } - tty->dwInMode &= ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT); - if (!SetConsoleMode(tty->ifd, tty->dwInMode)) + if (!SetConsoleMode(tty->ifd, dwInMode)) { /* Failed to set disable echo or line input mode */ return make_errno_error(env, "SetConsoleMode"); } - /* If we are changing from "-noshell" to a shell we - need to cancel any outstanding async io. This - will cause the enif_select to trigger which allows - us to do more cleanup in tty_read_nif. */ - if (tty->ifdOverlapped != INVALID_HANDLE_VALUE) { - debug("CancelIo on %d\r\n", tty->ifdOverlapped); - CancelIoEx(tty->ifdOverlapped, &tty->overlapped); + + if (!SetConsoleMode(tty->ofd, dwOutMode)) { + /* If we cannot disable NEWLINE_AUTO_RETURN we continue anyway as things work */ + ; } #endif /* __WIN32__ */ - tty->tty = 1; - - return atom_ok; -#else - return make_enotsup(env); -#endif -} - -static ERL_NIF_TERM tty_set_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { -#if defined(HAVE_TERMCAP) || defined(__WIN32__) - TTYResource *tty; - if (!enif_get_resource(env, argv[0], tty_rt, (void **)&tty)) - return enif_make_badarg(env); -#ifdef HAVE_TERMCAP - if (tty->tty && tcsetattr(tty->ifd, TCSANOW, &tty->tty_smode) < 0) { - return make_errno_error(env, "tcsetattr"); - } -#endif enif_self(env, &tty->self); enif_monitor_process(env, tty, &tty->self, NULL); + return atom_ok; #else return make_enotsup(env); @@ -1010,37 +1070,19 @@ static ERL_NIF_TERM tty_window_size_nif(ErlNifEnv* env, int argc, const ERL_NIF_ static ERL_NIF_TERM tty_select_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { TTYResource *tty; + #ifndef __WIN32__ extern int using_oldshell; /* set this to let the rest of erts know */ -#else - struct tty_reader *tty_reader; #endif + if (!enif_get_resource(env, argv[0], tty_rt, (void **)&tty)) return enif_make_badarg(env); #ifndef __WIN32__ using_oldshell = 0; - - enif_select(env, tty->ifd, ERL_NIF_SELECT_READ, tty, NULL, argv[1]); -#else - if (tty->tty || GetFileType(tty->ifd) != FILE_TYPE_CHAR) { - debug("Select on %d\r\n", tty->ifd); - enif_select(env, tty->ifd, ERL_NIF_SELECT_READ, tty, NULL, argv[1]); - } else { - tty->ifdOverlapped = CreateFile("CONIN$", GENERIC_READ, FILE_SHARE_READ, NULL, - OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); - enif_alloc_binary(1024, &tty->overlappedBuffer); - tty->overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); - debug("Calling ReadFile on %d\r\n", tty->ifdOverlapped); - if (!ReadFile(tty->ifdOverlapped, tty->overlappedBuffer.data, tty->overlappedBuffer.size, NULL, &tty->overlapped)) { - if (GetLastError() != ERROR_IO_PENDING) { - return make_errno_error(env, "ReadFile"); - } - } - debug("Select on %d\r\n", tty->overlapped.hEvent); - enif_select(env, tty->overlapped.hEvent, ERL_NIF_SELECT_READ, tty, NULL, argv[1]); - } #endif + debug("Select on %d\r\n", tty->ifd); + enif_select(env, tty->ifd, ERL_NIF_SELECT_READ, tty, NULL, argv[1]); enif_self(env, &tty->reader); enif_monitor_process(env, tty, &tty->reader, NULL); diff --git a/lib/kernel/src/group.erl b/lib/kernel/src/group.erl index 043761c43d98..17b4562ff6bd 100644 --- a/lib/kernel/src/group.erl +++ b/lib/kernel/src/group.erl @@ -67,12 +67,15 @@ }). -record(state, - { read_mode :: list | binary, + { read_type :: list | binary, driver :: pid(), echo :: boolean(), dumb :: boolean(), shell = noshell :: noshell | pid(), + %% Only used by dumb + terminal_mode = cooked :: raw | cooked | disabled, + %% Only used by xterm line_history :: [string()] | undefined, save_history :: boolean(), %% Whether get_line and get_until should save history @@ -129,7 +132,7 @@ init([Drv, Shell, Options]) -> State = #state{ driver = Drv, - read_mode = list, + read_type = list, dumb = Dumb, save_history = not Dumb, @@ -230,9 +233,7 @@ server(info, {io_request,From,ReplyAs,Req}, Data) when is_pid(From), ?IS_INPUT_R {next_state, if Data#state.dumb orelse not Data#state.echo -> dumb; true -> xterm end, Data#state{ input = #input_state{ from = From, reply_as = ReplyAs } }, - {next_event, internal, Req}}; -server(info, {Drv, echo, Bool}, Data = #state{ driver = Drv }) -> - {keep_state, Data#state{ echo = Bool }}; + {next_event, internal, Req} }; server(info, {Drv, _}, #state{ driver = Drv }) -> %% We postpone any Drv event sent to us as they are handled in xterm or dumb states {keep_state_and_data, postpone}; @@ -240,16 +241,29 @@ server(info, Msg, Data) -> handle_info(server, Msg, Data). %% This is the dumb terminal state, also used for noshell and xterm get_password + +%% When terminal_mode == raw, the terminal is in '{noshell,raw}' mode, which means that +%% for get_until and get_chars we change the behaviour a bit so that characters are +%% delivered as they are typed instead of at new-lines. +dumb(internal, {get_chars, Encoding, Prompt, N}, Data = #state{ terminal_mode = raw }) -> + dumb(input_request, {collect_chars_eager, N, Prompt, Encoding, fun get_chars_dumb/5}, Data); +dumb(internal, {get_line, Encoding, Prompt}, Data = #state{ terminal_mode = raw }) -> + dumb(input_request, {collect_line, [], Prompt, Encoding, fun get_line_dumb/5}, Data); +dumb(internal, {get_until, Encoding, Prompt, M, F, As}, Data = #state{ terminal_mode = raw }) -> + dumb(input_request, {get_until, {M, F, As}, Prompt, Encoding, fun get_chars_dumb/5}, Data); + dumb(internal, {get_chars, Encoding, Prompt, N}, Data) -> dumb(input_request, {collect_chars, N, Prompt, Encoding, fun get_chars_dumb/5}, Data); dumb(internal, {get_line, Encoding, Prompt}, Data) -> dumb(input_request, {collect_line, [], Prompt, Encoding, fun get_line_dumb/5}, Data); dumb(internal, {get_until, Encoding, Prompt, M, F, As}, Data) -> dumb(input_request, {get_until, {M, F, As}, Prompt, Encoding, fun get_line_dumb/5}, Data); + dumb(internal, {get_password, _Encoding}, Data) -> %% TODO: Implement for noshell by disabling characters echo if isatty(stdin) io_reply(Data, {error, enotsup}), pop_state(Data); + dumb(input_request, {CollectF, CollectAs, Prompt, Encoding, GetFun}, Data = #state{ input = OrigInputState }) -> @@ -278,7 +292,7 @@ dumb(data, Buf, Data = #state{ input = #input_state{ prompt_bytes = Pbs, encodin io_reply(Data, {error,{no_translation, unicode, latin1}}), pop_state(Data#state{ buf = [] }); {done, NewLine, RemainBuf} -> - EncodedLine = cast(NewLine, Data#state.read_mode, Encoding), + EncodedLine = cast(NewLine, Data#state.read_type, Encoding), case io_lib:CollectF(State, EncodedLine, Encoding, CollectAs) of {stop, eof, _} -> io_reply(Data, eof), @@ -293,9 +307,11 @@ dumb(data, Buf, Data = #state{ input = #input_state{ prompt_bytes = Pbs, encodin io_reply(Data, {error,err_func(io_lib, CollectF, CollectAs)}), pop_state(Data#state{ buf = [] }); NewState -> + [Data#state.driver ! {self(), read, io_lib:CollectF(NewState)} || Data#state.shell =:= noshell], dumb(data, RemainBuf, Data#state{ input = InputState#input_state{ cont = undefined, io_lib_state = NewState } }) end; {more_chars, NewCont} -> + [Data#state.driver ! {self(), read, 0} || Data#state.shell =:= noshell], {keep_state, Data#state{ input = InputState#input_state{ cont = NewCont } } } end; @@ -361,7 +377,7 @@ xterm(data, Buf, Data = #state{ input = #input_state{ %% Get a single line using edlin case get_line_edlin(Buf, Pbs, Cont, Lines, Encoding, Data) of {done, NewLines, RemainBuf} -> - CurrentLine = cast(edlin:current_line(NewLines), Data#state.read_mode, Encoding), + CurrentLine = cast(edlin:current_line(NewLines), Data#state.read_type, Encoding), case io_lib:CollectF(start, CurrentLine, Encoding, CollectAs) of {stop, eof, _} -> io_reply(Data, eof), @@ -395,7 +411,7 @@ xterm(data, Buf, Data = #state{ input = #input_state{ {'EXIT',_} -> io_reply(Data, {error,err_func(io_lib, CollectF, CollectAs)}), pop_state(Data#state{ buf = [] }); - _M -> + _NewState -> xterm(data, RemainBuf, Data#state{ input = InputState#input_state{ cont = undefined, lines = NewLines} }) end; {blink, NewCont} -> @@ -427,6 +443,15 @@ handle_info(State, {Drv, {data, Buf}}, Data = #state{ driver = Drv }) -> ?MODULE:State(data, Buf, Data); handle_info(State, {Drv, eof}, Data = #state{ driver = Drv }) -> ?MODULE:State(data, eof, Data); +handle_info(_State, {Drv, echo, Bool}, Data = #state{ driver = Drv }) -> + {keep_state, Data#state{ echo = Bool } }; +handle_info(_State, {Drv, {error, _} = Error}, Data = #state{ driver = Drv }) -> + io_reply(Data, Error), + pop_state(Data#state{ buf = [] }); +handle_info(_State, {Drv, terminal_mode, Mode}, Data = #state{ driver = Drv }) -> + noshell = Data#state.shell, + true = lists:member(Mode, [raw, cooked, disabled]), + {keep_state, Data#state{ terminal_mode = Mode }}; handle_info(_State, {io_request, From, ReplyAs, {setopts, Opts}}, Data) -> {Reply, NewData} = setopts(Opts, Data), @@ -717,9 +742,9 @@ do_setopts(Opts, Data) -> undefined -> ok end, - ReadMode = + ReadType = case proplists:get_value(binary, Opts, - case Data#state.read_mode of + case Data#state.read_type of binary -> true; _ -> false end) of @@ -729,7 +754,7 @@ do_setopts(Opts, Data) -> list end, LineHistory = proplists:get_value(line_history, Opts, true), - {ok, Data#state{ expand_fun = ExpandFun, echo = Echo, read_mode = ReadMode, + {ok, Data#state{ expand_fun = ExpandFun, echo = Echo, read_type = ReadType, save_history = LineHistory }}. normalize_expand_fun(Options, Default) -> @@ -758,7 +783,7 @@ getopts(Data) -> _ -> false end}, - Bin = {binary, case Data#state.read_mode of + Bin = {binary, case Data#state.read_type of binary -> true; _ -> @@ -1087,7 +1112,8 @@ get_line_dumb(Buf, _Pbs, Cont, ToEnc, Data = #state{ driver = Drv }) -> EditLineRes = if - Data#state.shell =:= noshell -> edit_line_noshell(cast(Buf, list), Cont, []); + Data#state.shell =:= noshell -> + edit_line_noshell(cast(Buf, list), Cont, [], Data#state.terminal_mode); true -> edit_line_dumb(cast(Buf, list), Cont, []) end, @@ -1160,16 +1186,18 @@ edit_line_dumb([Char|Cs],Chars, Rs) -> edit_line_dumb(Cs,[Char|Chars], [{put_chars, unicode, [Char]}|Rs]). %% This is used by noshell to get just get everything until the next \n -edit_line_noshell(eof, [], _) -> +edit_line_noshell(eof, [], _, _) -> eof; -edit_line_noshell(eof, Chars, Rs) -> +edit_line_noshell(eof, Chars, Rs, _) -> {done, Chars, eof, lists:reverse(Rs)}; -edit_line_noshell([],Chars, Rs) -> +edit_line_noshell([],Chars, Rs, _) -> {more, Chars, lists:reverse(Rs)}; -edit_line_noshell([NL|Cs],Chars, Rs) when NL =:= $\n -> +edit_line_noshell([NL|Cs],Chars, Rs, raw) when NL =:= $\r -> + {done, [$\n | Chars], Cs, lists:reverse([{put_chars, unicode, "\r\n"}|Rs])}; +edit_line_noshell([NL|Cs],Chars, Rs, _) when NL =:= $\n -> {done, [$\n | Chars], Cs, lists:reverse([{put_chars, unicode, "\n"}|Rs])}; -edit_line_noshell([Char|Cs],Chars, Rs) -> - edit_line_noshell(Cs, [Char|Chars], [{put_chars, unicode, [Char]}|Rs]). +edit_line_noshell([Char|Cs],Chars, Rs, TerminalMode) -> + edit_line_noshell(Cs, [Char|Chars], [{put_chars, unicode, [Char]}|Rs], TerminalMode). %% Handling of the line history stack new_stack(Ls) -> {stack,Ls,{},[]}. diff --git a/lib/kernel/src/prim_tty.erl b/lib/kernel/src/prim_tty.erl index 1eba84ab53e9..45600663f7ef 100644 --- a/lib/kernel/src/prim_tty.erl +++ b/lib/kernel/src/prim_tty.erl @@ -106,21 +106,24 @@ %% to previous line automatically. -export([init/1, init_ssh/3, reinit/2, isatty/1, handles/1, unicode/1, unicode/2, - handle_signal/2, window_size/1, update_geometry/3, handle_request/2, write/2, write/3, + handle_signal/2, window_size/1, update_geometry/3, handle_request/2, + write/2, write/3, npwcwidth/1, npwcwidth/2, ansi_regexp/0, ansi_color/2]). --export([reader_stop/1, disable_reader/1, enable_reader/1, is_reader/2, is_writer/2]). +-export([reader_stop/1, disable_reader/1, enable_reader/1, read/1, read/2, + is_reader/2, is_writer/2]). --nifs([isatty/1, tty_create/0, tty_init/3, tty_set/1, setlocale/1, - tty_select/2, tty_window_size/1, tty_encoding/1, write_nif/2, read_nif/2, isprint/1, +-nifs([isatty/1, tty_create/0, tty_init/2, setlocale/1, + tty_select/2, tty_window_size/1, + tty_encoding/1, tty_is_open/2, write_nif/2, read_nif/3, isprint/1, wcwidth/1, wcswidth/1, sizeof_wchar/0, tgetent_nif/1, tgetnum_nif/1, tgetflag_nif/1, tgetstr_nif/1, tgoto_nif/1, tgoto_nif/2, tgoto_nif/3]). --export([reader_loop/5, writer_loop/2]). +-export([reader_loop/2, writer_loop/2]). %% Exported in order to remove "unused function" warning --export([sizeof_wchar/0, wcswidth/1, tgoto/1, tgoto/2, tgoto/3]). +-export([sizeof_wchar/0, wcswidth/1, tgoto/1, tgoto/2, tgoto/3, tty_is_open/2]). %% proc_lib exports -export([reader/1, writer/1]). @@ -138,7 +141,7 @@ -record(state, {tty :: tty() | undefined, reader :: {pid(), reference()} | undefined, writer :: {pid(), reference()} | undefined, - options, + options = #{ input => cooked, output => cooked } :: options(), unicode = true :: boolean(), lines_before = [], %% All lines before the current line in reverse order lines_after = [], %% All lines after the current line. @@ -166,12 +169,8 @@ ansi_regexp }). --type options() :: #{ tty => boolean(), - input => boolean(), - canon => boolean(), - echo => boolean(), - sig => boolean() - }. +-type options() :: #{ input := cooked | raw | disabled, + output := raw | cooked }. -type request() :: {putc_raw, binary()} | {putc, unicode:unicode_binary()} | @@ -241,7 +240,7 @@ on_load(Extra) -> -spec window_size(state()) -> {ok, {non_neg_integer(), non_neg_integer()}} | {error, term()}. window_size(State = #state{ tty = TTY }) -> case tty_window_size(TTY) of - {error, enotsup} when map_get(tty, State#state.options) -> + {error, enotsup} when map_get(input, State#state.options) =:= raw -> %% When the TTY is enabled, we should return a "dummy" row and column %% when we cannot find the proper size. {ok, {State#state.cols, State#state.rows}}; @@ -275,28 +274,25 @@ init(UserOptions) when is_map(UserOptions) -> init_term(#state{ tty = TTY, unicode = UnicodeMode, options = Options, ansi_regexp = ANSI_RE_MP }). init_term(State = #state{ tty = TTY, options = Options }) -> TTYState = - case maps:get(tty, Options) of - true -> - %% If a reader has been started already, we disable it to avoid race conditions when - %% upgrading the terminal - [disable_reader(State) || State#state.reader =/= undefined], - - try - case tty_init(TTY, stdout, Options) of - ok -> ok; - {error, enotsup} -> error(enotsup) - end, - NewState = init(State, os:type()), - ok = tty_set(TTY), - - NewState - after - [enable_reader(State) || State#state.reader =/= undefined] - end; - false -> + case maps:get(input, Options) of + raw -> + init(State, os:type()); + Else when Else =:= cooked; Else =:= disabled -> State end, + try + [disable_reader(State) || State#state.reader =/= undefined], + + case tty_init(TTY, Options) of + ok -> ok; + {error, enotsup} -> error(enotsup) + end + + after + [enable_reader(State) || State#state.reader =/= undefined] + end, + WriterState = if TTYState#state.writer =:= undefined -> {ok, Writer} = proc_lib:start_link(?MODULE, writer, [State#state.tty]), @@ -305,7 +301,7 @@ init_term(State = #state{ tty = TTY, options = Options }) -> TTYState end, ReaderState = - case {maps:get(input, Options), TTYState#state.reader} of + case {maps:get(input, Options) =/= disabled, TTYState#state.reader} of {true, undefined} -> DefaultReaderEncoding = if State#state.unicode -> utf8; not State#state.unicode -> latin1 @@ -352,18 +348,18 @@ init_ssh(UserOptions, {Cols, Rows}, IOEncoding) -> -spec reinit(state(), options()) -> state(). -reinit(State, UserOptions) -> - init_term(State#state{ options = options(UserOptions) }). +reinit(State = #state{ options = OldOptions }, UserOptions) -> + case options(UserOptions) of + OldOptions -> State; + _ -> + init_term(State#state{ options = options(UserOptions) }) + end. options(UserOptions) -> - maps:merge( - #{ input => true, - tty => true, - canon => false, - echo => false }, UserOptions). + maps:merge(#{ input => raw, output => cooked }, UserOptions). + init(State, ssh) -> State#state{ xn = true }; - init(State, {unix,_}) -> case os:getenv("TERM") of @@ -506,7 +502,7 @@ handle_signal(State, sigwinch) -> handle_signal(State, resize) -> update_geometry(State); handle_signal(State, sigcont) -> - tty_set(State#state.tty), + tty_init(State#state.tty, State#state.options), State. -spec disable_reader(state()) -> ok. @@ -517,6 +513,18 @@ disable_reader(#state{ reader = {ReaderPid, _} }) -> enable_reader(#state{ reader = {ReaderPid, _} }) -> ok = call(ReaderPid, enable). +-spec read(state()) -> ok. +read(#state{ reader = {ReaderPid, _} }) -> + ReaderPid ! {read, infinity}, + ok. + +-spec read(state(), pos_integer()) -> ok. +read(State, 0) -> + read(State, 1024); +read(#state{ reader = {ReaderPid, _} }, N) -> + ReaderPid ! {read, N}, + ok. + call(Pid, Msg) -> Alias = erlang:monitor(process, Pid, [{alias, reply_demonitor}]), Pid ! {Alias, Msg}, @@ -537,69 +545,84 @@ reader([TTY, Encoding, Parent]) -> utf8 -> Encoding; Else -> Else end, - reader_loop(TTY, Parent, ReaderRef, FromEnc, <<>>). + reader_loop(#{ tty => TTY, parent => Parent, + reader => ReaderRef, enc => FromEnc, read => 0, + ready_input => false}, <<>>). -reader_loop(TTY, Parent, ReaderRef, FromEnc, Acc) -> +reader_loop(State = #{ tty := TTY, reader := ReaderRef, enc := FromEnc, read := Read, + ready_input := ReadyInput }, Acc) -> receive {DisableAlias, disable} -> DisableAlias ! {DisableAlias, ok}, receive {EnableAlias, enable} -> EnableAlias ! {EnableAlias, ok}, - ?MODULE:reader_loop(TTY, Parent, ReaderRef, FromEnc, Acc) + ?MODULE:reader_loop(State, Acc) end; {set_unicode_state, _} when FromEnc =:= {utf16, little} -> - ?MODULE:reader_loop(TTY, Parent, ReaderRef, FromEnc, Acc); + ?MODULE:reader_loop(State, Acc); {set_unicode_state, Bool} -> NewFromEnc = if Bool -> utf8; not Bool -> latin1 end, - ?MODULE:reader_loop(TTY, Parent, ReaderRef, NewFromEnc, Acc); + ?MODULE:reader_loop(State#{ enc := NewFromEnc }, Acc); {_Alias, stop} -> ok; - {select, TTY, ReaderRef, ready_input} -> - %% This call may block until data is available - case read_nif(TTY, ReaderRef) of - {error, closed} -> - Parent ! {ReaderRef, eof}, - ok; - {error, aborted} -> - %% The read operation was aborted. This only happens on - %% Windows when we change from "noshell" to "newshell". - %% When it happens we need to re-read the tty_encoding as - %% it has changed. - reader_loop(TTY, Parent, ReaderRef, tty_encoding(TTY), Acc); - {ok, <<>>} -> - %% EAGAIN or EINTR - ?MODULE:reader_loop(TTY, Parent, ReaderRef, FromEnc, Acc); - {ok, UtfXBytes} -> - - %% read_nif may have blocked for a long time, so we check if - %% there have been any changes to the unicode state before - %% decoding the data. - UpdatedFromEnc = flush_unicode_state(FromEnc), - - {Bytes, NewAcc, NewFromEnc} = - case unicode:characters_to_binary([Acc, UtfXBytes], UpdatedFromEnc, utf8) of - {error, B, Error} -> - %% We should only be able to get incorrect encoded data when - %% using utf8 - UpdatedFromEnc = utf8, - Parent ! {self(), set_unicode_state, false}, + {read, N} when ReadyInput -> + reader_read(State#{ read := N }, Acc); + {read, N} when not ReadyInput -> + ?MODULE:reader_loop(State#{ read := N }, Acc); + {select, TTY, ReaderRef, ready_input} when Read > 0; Read =:= infinity -> + reader_read(State, Acc); + {select, TTY, ReaderRef, ready_input} when Read =:= 0 -> + ?MODULE:reader_loop(State#{ ready_input := true }, Acc); + _M -> + % erlang:display(_M), + ?MODULE:reader_loop(State, Acc) + end. + +reader_read(State = #{ tty := TTY, parent := Parent, reader := ReaderRef, + enc := FromEnc, read := Read }, Acc) -> + %% This call may block until data is available + case read_nif(TTY, ReaderRef, if Read =:= infinity -> 1024; true -> Read end) of + {error, closed} -> + Parent ! {ReaderRef, eof}, + ok; + {ok, <<>>} -> + %% EAGAIN or EINTR + ?MODULE:reader_loop(State#{ ready_input := false }, Acc); + {ok, UtfXBytes} -> + + %% read_nif may have blocked for a long time, so we check if + %% there have been any changes to the unicode state before + %% decoding the data. + UpdatedFromEnc = flush_unicode_state(FromEnc), + + {Bytes, NewAcc, NewFromEnc} = + case unicode:characters_to_binary([Acc, UtfXBytes], UpdatedFromEnc, utf8) of + {error, B, Error} -> + %% We should only be able to get incorrect encoded data when + %% using utf8 + UpdatedFromEnc = utf8, + Parent ! {self(), set_unicode_state, false}, + receive + {set_unicode_state, false} -> receive - {set_unicode_state, false} -> - receive - {Parent, set_unicode_state, _} -> ok - end - end, - Latin1Chars = unicode:characters_to_binary(Error, latin1, utf8), - {<>, <<>>, latin1}; - {incomplete, B, Inc} -> - {B, Inc, UpdatedFromEnc}; - B when is_binary(B) -> - {B, <<>>, UpdatedFromEnc} + {Parent, set_unicode_state, _} -> ok + end end, - Parent ! {ReaderRef, {data, Bytes}}, - ?MODULE:reader_loop(TTY, Parent, ReaderRef, NewFromEnc, NewAcc) - end + Latin1Chars = unicode:characters_to_binary(Error, latin1, utf8), + {<>, <<>>, latin1}; + {incomplete, B, Inc} -> + {B, Inc, UpdatedFromEnc}; + B when is_binary(B) -> + {B, <<>>, UpdatedFromEnc} + end, + Parent ! {ReaderRef, {data, Bytes}}, + ResetRead = if + Read =:= infinity -> infinity; + true -> 0 + end, + ?MODULE:reader_loop(State#{ read := ResetRead, ready_input := false, + enc := NewFromEnc }, NewAcc) end. flush_unicode_state(FromEnc) -> @@ -646,7 +669,7 @@ writer_loop(TTY, WriterRef) -> end. -spec handle_request(state(), request()) -> {erlang:iovec(), state()}. -handle_request(State = #state{ options = #{ tty := false } }, Request) -> +handle_request(State = #state{ options = #{ output := raw } }, Request) -> case Request of {putc_raw, Binary} -> {Binary, State}; @@ -1428,9 +1451,7 @@ isatty(_Fd) -> -spec tty_create() -> {ok, tty()}. tty_create() -> erlang:nif_error(undef). -tty_init(_TTY, _Fd, _Options) -> - erlang:nif_error(undef). -tty_set(_TTY) -> +tty_init(_TTY, _Options) -> erlang:nif_error(undef). setlocale(_TTY) -> erlang:nif_error(undef). @@ -1438,9 +1459,11 @@ tty_select(_TTY, _ReadRef) -> erlang:nif_error(undef). tty_encoding(_TTY) -> erlang:nif_error(undef). +tty_is_open(_TTY, _Fd) -> + erlang:nif_error(undef). write_nif(_TTY, _IOVec) -> erlang:nif_error(undef). -read_nif(_TTY, _Ref) -> +read_nif(_TTY, _Ref, _N) -> erlang:nif_error(undef). tty_window_size(_TTY) -> erlang:nif_error(undef). diff --git a/lib/kernel/src/user_drv.erl b/lib/kernel/src/user_drv.erl index d90bb8cb6921..dabfc91ab78a 100644 --- a/lib/kernel/src/user_drv.erl +++ b/lib/kernel/src/user_drv.erl @@ -104,6 +104,7 @@ -record(state, { tty :: prim_tty:state() | undefined, write :: reference() | undefined, read :: reference() | undefined, + terminal_mode :: raw | cooked | disabled, shell_started = new :: new | old | false, editor :: #editor{} | undefined, user :: pid(), @@ -111,9 +112,10 @@ groups, queue }). -type shell() :: {module(), atom(), [term()]} | {node(), module(), atom(), [term()]}. --type arguments() :: #{ initial_shell => noshell | shell() | - {remote, unicode:charlist()} | {remote, unicode:charlist(), {module(), atom(), [term()]}}, - input => boolean() }. +-type arguments() :: + #{ initial_shell => noshell | shell() | + {remote, unicode:charlist()} | {remote, unicode:charlist(), {module(), atom(), [term()]}}, + input => cooked | raw | disabled }. %% Default line editing shell -spec start() -> pid(). @@ -162,20 +164,22 @@ init(Args) -> IsTTY = prim_tty:isatty(stdin) =:= true andalso prim_tty:isatty(stdout) =:= true, StartShell = maps:get(initial_shell, Args, undefined) =/= noshell, OldShell = maps:get(initial_shell, Args, undefined) =:= oldshell, + try if not IsTTY andalso StartShell; OldShell -> error(enotsup); IsTTY, StartShell -> - TTYState = prim_tty:init(#{}), + TTYState = prim_tty:init(#{ input => raw, + output => cooked }), init_standard_error(TTYState, true), - {ok, init, {Args, #state{ user = start_user() } }, + {ok, init, {Args, #state{ terminal_mode = raw, user = start_user() } }, {next_event, internal, TTYState}}; - true -> - TTYState = prim_tty:init(#{input => maps:get(input, Args, true), - tty => false}), + true -> + TTYState = prim_tty:init( + #{ input => maps:get(input, Args), output => raw }), init_standard_error(TTYState, false), - {ok, init, {Args,#state{ user = start_user() } }, + {ok, init, {Args,#state{ terminal_mode = maps:get(input, Args), user = start_user() } }, {next_event, internal, TTYState}} end catch error:enotsup -> @@ -186,9 +190,9 @@ init(Args) -> %% The oldshell mode is important as it is %% the mode used when running erlang in an %% emacs buffer. - CatchTTYState = prim_tty:init(#{tty => false}), + CatchTTYState = prim_tty:init(#{ input => cooked, output => raw }), init_standard_error(CatchTTYState, false), - {ok, init, {Args,#state{ shell_started = old, user = start_user() } }, + {ok, init, {Args,#state{ terminal_mode = cooked, shell_started = old, user = start_user() } }, {next_event, internal, CatchTTYState}} end. @@ -245,6 +249,7 @@ exit_on_remote_shell_error(_, _, Result) -> %% We have been started with -noshell. In this mode the current_group is %% the `user` group process. init_noshell(State) -> + State#state.user ! {self(), terminal_mode, State#state.terminal_mode}, init_shell(State#state{ shell_started = false }, ""). init_remote_shell(State, Node, {M, F, A}) -> @@ -336,7 +341,11 @@ init_local_shell(State, InitialShell) -> init_shell(State, Slogan) -> - init_standard_error(State#state.tty, State#state.shell_started =:= new), + init_standard_error(State#state.tty, State#state.terminal_mode =:= raw), + + %% Tell the reader to read greedily if there is a shell + [prim_tty:read(State#state.tty) || State#state.shell_started =/= false], + Curr = gr_cur_pid(State#state.groups), put(current_group, Curr), {next_state, server, State#state{ current_group = gr_cur_pid(State#state.groups) }, @@ -367,16 +376,28 @@ server({call, From}, {start_shell, Args}, not IsTTY andalso StartShell; OldShell -> error(enotsup); IsTTY, StartShell -> - NewTTY = prim_tty:reinit(TTY, #{ }), - State#state{ tty = NewTTY, - shell_started = new }; - true -> - NewTTY = prim_tty:reinit(TTY, #{ tty => false }), - State#state{ tty = NewTTY, shell_started = false } + NewTTY = prim_tty:reinit(TTY, #{ input => raw, output => cooked }), + State#state{ tty = NewTTY, terminal_mode = raw, shell_started = new }; + not StartShell -> + Input = maps:get(input, Args), + if not IsTTY andalso Input =:= raw -> + error(enotsup); + true -> + State#state{ + terminal_mode = Input, + tty = prim_tty:reinit(TTY, #{ input => Input, + output => raw }), + shell_started = false } + end end catch error:enotsup -> - NewTTYState = prim_tty:reinit(TTY, #{ tty => false }), - State#state{ tty = NewTTYState, shell_started = old } + NewTTYState = prim_tty:reinit(TTY, #{ input => cooked, output => raw }), + Shell = if StartShell -> + old; + true -> + false + end, + State#state{ terminal_mode = cooked, tty = NewTTYState, shell_started = Shell } end, #{ read := ReadHandle, write := WriteHandle } = prim_tty:handles(NewState#state.tty), NewHandleState = NewState#state { @@ -386,7 +407,12 @@ server({call, From}, {start_shell, Args}, {Result, Reply} = case maps:get(initial_shell, Args, undefined) of noshell -> - {init_noshell(NewHandleState), ok}; + case maps:get(input, Args) =:= NewHandleState#state.terminal_mode of + true -> + {init_noshell(NewHandleState), ok}; + false -> + {init_noshell(NewHandleState), {error, enotsup}} + end; {remote, Node} -> case init_remote_shell(NewHandleState, Node, {shell, start, []}) of {error, _} = Error -> @@ -444,6 +470,17 @@ server(info, {ReadHandle,eof}, State = #state{ read = ReadHandle }) -> server(info,{ReadHandle,{signal,Signal}}, State = #state{ tty = TTYState, read = ReadHandle }) -> {keep_state, State#state{ tty = prim_tty:handle_signal(TTYState, Signal) }}; +server(info, {Requester, read, N}, State = #state{ tty = TTYState }) + when Requester =:= State#state.current_group -> + %% Only allowed when current_group == user + true = State#state.current_group =:= State#state.user, + ok = prim_tty:read(TTYState, N), + keep_state_and_data; + +server(info, {Requester, read, _N}, _State) -> + Requester ! {self(), {error, enotsup}}, + keep_state_and_data; + server(info, {Requester, tty_geometry}, #state{ tty = TTYState }) -> case prim_tty:window_size(TTYState) of {ok, Geometry} -> diff --git a/lib/kernel/src/user_sup.erl b/lib/kernel/src/user_sup.erl index 96ee518ab5e5..7525077d450c 100644 --- a/lib/kernel/src/user_sup.erl +++ b/lib/kernel/src/user_sup.erl @@ -124,13 +124,13 @@ check_flags([{nouser, []} |T], Attached, _) -> check_flags(T, Attached, nouser); check_flags([{user, [User]} | T], Attached, _) -> check_flags(T, Attached, {list_to_atom(User), start, []}); check_flags([{noshell, []} | T], Attached, _) -> - check_flags(T, Attached, {user_drv, start, [#{ initial_shell => noshell }]}); + check_flags(T, Attached, {user_drv, start, [#{ initial_shell => noshell, input => cooked }]}); check_flags([{oldshell, []} | T], false, _) -> %% When running in detached mode, we ignore any -oldshell flags as we do not %% want input => true to be set as they may halt the node (on bsd) check_flags(T, false, {user_drv, start, [#{ initial_shell => oldshell }]}); check_flags([{noinput, []} | T], Attached, _) -> - check_flags(T, Attached, {user_drv, start, [#{ initial_shell => noshell, input => false }]}); + check_flags(T, Attached, {user_drv, start, [#{ initial_shell => noshell, input => disabled }]}); check_flags([{master, [Node]} | T], Attached, _) -> check_flags(T, Attached, {master, list_to_atom(Node)}); check_flags([_H | T], Attached, User) -> check_flags(T, Attached, User); diff --git a/lib/kernel/test/interactive_shell_SUITE.erl b/lib/kernel/test/interactive_shell_SUITE.erl index 05f1a61e178d..7bb085043c1d 100644 --- a/lib/kernel/test/interactive_shell_SUITE.erl +++ b/lib/kernel/test/interactive_shell_SUITE.erl @@ -40,6 +40,7 @@ init_per_testcase/2, end_per_testcase/2, get_columns_and_rows/1, exit_initial/1, job_control_local/1, job_control_remote/1,stop_during_init/1,wrap/1, + noshell_raw/1, shell_history/1, shell_history_resize/1, shell_history_eaccess/1, shell_history_repair/1, shell_history_repair_corrupt/1, shell_history_corrupt/1, @@ -67,13 +68,15 @@ external_editor/1, external_editor_visual/1, external_editor_unicode/1, shell_ignore_pager_commands/1]). +-export([get_until/2]). + -export([test_invalid_keymap/1, test_valid_keymap/1]). %% Exports for custom shell history module -export([load/0, add/1]). %% For custom prompt testing -export([prompt/1]). -export([output_to_stdout_slowly/1]). --record(tmux, {peer, node, name, orig_location }). +-record(tmux, {peer, node, name, ssh_server_name, orig_location }). suite() -> [{ct_hooks,[ts_install_cth]}, {timetrap,{minutes,3}}]. @@ -91,6 +94,7 @@ groups() -> ctrl_keys, stop_during_init, wrap, shell_invalid_ansi, shell_get_password, + noshell_raw, {group, shell_history}, {group, remsh}]}, {shell_history, [], @@ -293,10 +297,15 @@ end_per_testcase(_Case, Config) -> -endif. string_to_term(Str) -> - {ok,Tokens,_EndLine} = erl_scan:string(Str ++ "."), - {ok,AbsForm} = erl_parse:parse_exprs(Tokens), - {value,Value,_Bs} = erl_eval:exprs(AbsForm, erl_eval:new_bindings()), - Value. + try + {ok,Tokens,_EndLine} = erl_scan:string(Str ++ "."), + {ok,AbsForm} = erl_parse:parse_exprs(Tokens), + {value,Value,_Bs} = erl_eval:exprs(AbsForm, erl_eval:new_bindings()), + Value + catch E:R:ST -> + ct:log("Could not parse: ~ts~n", [Str]), + erlang:raise(E,R,ST) + end. run_unbuffer_escript(Rows, Columns, EScript, NoTermStdIn, NoTermStdOut) -> DataDir = filename:join(filename:dirname(code:which(?MODULE)), "interactive_shell_SUITE_data"), @@ -525,9 +534,9 @@ shell_format(Config) -> send_tty(Term1, "Down"), tmux(["resize-window -t ",tty_name(Term1)," -x ",200]), timer:sleep(1000), - send_stdin(Term1, "shell:format_shell_func(\"emacs -batch \${file} -l \"\n"), - send_stdin(Term1, EmacsFormat), - send_stdin(Term1, "\" -f emacs-format-function\").\n"), + send_tty(Term1, "shell:format_shell_func(\"emacs -batch \${file} -l \"\n"), + send_tty(Term1, EmacsFormat), + send_tty(Term1, "\" -f emacs-format-function\").\n"), check_content(Term1, "{shell,erl_pp_format_func}"), send_tty(Term1, "Up"), send_tty(Term1, "Up"), @@ -538,7 +547,7 @@ shell_format(Config) -> check_content(Term1, "fun\\(X\\) ->\\s*.. X\\s*.. end."), send_tty(Term1, "Down"), check_content(Term1, ">$"), - send_stdin(Term1, "shell:format_shell_func({bad,format}).\n"), + send_tty(Term1, "shell:format_shell_func({bad,format}).\n"), send_tty(Term1, "Up"), send_tty(Term1, "Up"), send_tty(Term1, "\n"), @@ -1917,11 +1926,11 @@ setup_tty(Config) -> check_content(Tmux,"Enter password for \"foo\""), "" = tmux("send -t " ++ ClientName ++ " bar Enter"), timer:sleep(1000), - check_content(Tmux,"\\d+>"); + check_content(Tmux,"\\d+\n?>"), + Tmux#tmux{ ssh_server_name = Name }; true -> - ok - end, - Tmux. + Tmux + end. get_top_parent_test_group(Config) -> maybe @@ -1979,6 +1988,8 @@ prompt(L) -> stop_tty(Term) -> catch peer:stop(Term#tmux.peer), ct:log("~ts",[get_content(Term, "-e")]), + [ct:log("~ts",[get_content(Term#tmux{ name = Term#tmux.ssh_server_name }, "-e")]) + || Term#tmux.ssh_server_name =/= undefined], % "" = tmux("kill-window -t " ++ Term#tmux.name), ok. @@ -2186,6 +2197,142 @@ wrap(Config) when is_list(Config) -> end, ok. +noshell_raw(Config) -> + + case proplists:get_value(default_shell, Config) of + new -> + + TCGl = group_leader(), + TC = self(), + + TestcaseFun = fun() -> + link(TC), + group_leader(whereis(user), self()), + + try + %% Make sure we are in unicode encoding + unicode = proplists:get_value(encoding, io:getopts()), + + "\fhello\n" = io:get_line("1> "), + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + ok = shell:start_interactive({noshell, raw}), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that we can receive 1 char when N is 100 + [$\^p] = io:get_chars("2> ", 100), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that we only receive 1 char when N is 1 + "a" = io:get_chars("3> ", 1), + "bc" = io:get_chars("", 2), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that get_chars counts characters not bytes + ok = io:setopts([binary]), + ~b"å" = io:get_chars("4> ", 1), + ~b"äö" = io:get_chars("", 2), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that echo works + ok = io:setopts([{echo, true}]), + ~b"åäö" = io:get_chars("5> ", 100), + ok = io:setopts([{echo, false}]), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that get_line works + ~b"a\n" = io:get_line("6> "), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that get_until works + ok = io:setopts([{echo, true}]), + ~b"a,b,c" = io:request({get_until, unicode, "7> ", ?MODULE, get_until, []}), + ok = io:setopts([{echo, false}]), + + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + + %% Test that we can go back to cooked mode + ok = shell:start_interactive({noshell, cooked}), + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + ~b"abc" = io:get_chars(user, "8> ", 3), + io:format(TCGl, "TC Line: ~p~n", [?LINE]), + ~b"\n" = io:get_chars(user, "", 1), + + io:format("exit") + catch E:R:ST -> + io:format(TCGl, "~p", [{E, R, ST}]) + end + end, + + rtnode:run( + [ + {eval, fun() -> spawn(TestcaseFun), ok end}, + + %% Test io:get_line in cookec mode + {expect, "1> $"}, + {putline, "hello"}, + + %% Test io:get_chars 100 in raw mode + {expect, "2> $"}, + {putdata, [$\^p]}, + + %% Test io:get_chars 1 in raw mode + {expect, "3> $"}, + {putdata, "abc"}, + + %% Test io:get_chars unicode + {expect, "4> $"}, + {putdata, ~b"åäö"}, + + %% Test that raw + echo works + {expect, "5> $"}, + {putdata, ~b"åäö"}, + {expect, "åäö$"}, + + %% Test io:get_line in raw mode + {expect, "6> $"}, + {putdata, "a\r"}, %% When in raw mode, \r is newline. + + %% Test io:get_until in raw mode + {expect, "7> $"}, + {putline, "a"}, + {expect, "a\n7> $"}, + {putline, "b"}, + {expect, "b\n7> $"}, + {putdata, "c."}, + {expect, "c.$"}, + + %% Test io:get_line in cooked mode + {expect, "8> $"}, + {putdata, "a"}, + {expect, "a"}, + {putdata, "b"}, + {expect, "b"}, + {putline, "c"}, + {expect, "c\r\n$"}, + + {expect, "exit$"} + ], [], [], + ["-noshell","-pz",filename:dirname(code:which(?MODULE))]); + _ -> ok + end, + ok. + +get_until(start, NewChars) -> + get_until([], NewChars); +get_until(State, NewChars) -> + Chars = State ++ NewChars, + case string:split(Chars, ".") of + [Chars] -> {more, Chars ++ ","}; + [Before, After] -> {done, [C || C <- Before, C =/= $\n], After} + end. + + %% This testcase tests that shell_history works as it should. %% We use Ctrl + P = Cp=[$\^p] in order to navigate up %% We use Ctrl + N = Cp=[$\^n] in order to navigate down diff --git a/lib/stdlib/doc/assets/pshell.es b/lib/stdlib/doc/assets/pshell.es new file mode 100755 index 000000000000..b2cb0583dfb1 --- /dev/null +++ b/lib/stdlib/doc/assets/pshell.es @@ -0,0 +1,87 @@ +#!/usr/bin/env escript +%% pshell.es +-export([start/0]). +main(_Args) -> + shell:start_interactive({?MODULE, start, []}), + timer:sleep(infinity). %% Make sure the escript does not exit + +-spec start() -> pid(). +start() -> + spawn(fun() -> + io:setopts([{expand_fun, fun expand_fun/1}]), + io:format("Starting process inspection shell~n"), + loop() + end). + +-spec expand_fun(ReverseLine :: string()) -> {yes, string(), list(string())} | + {no, nil(), nil()}. +expand_fun("") -> + {yes, "", ["list", "inspect", "suspend", "resume"]}; +expand_fun(Curr) -> + expand_fun(lists:reverse(Curr), ["list", "inspect", "suspend", "resume"]). + +expand_fun(_Curr, []) -> + {no, "", []}; +expand_fun(Curr, [H | T]) -> + case lists:prefix(Curr, H) of + true -> + {yes, lists:reverse(lists:reverse(H) -- lists:reverse(Curr)), []}; + false -> + expand_fun(Curr, T) + end. + +loop() -> + case io:get_line("> ") of + eof -> ok; + {error, Reason} -> exit(Reason); + Data -> eval(string:trim(Data)) + end, + loop(). + +eval("list") -> + Format = " ~.10ts | ~.10ts | ~.10ts~n", + io:format(Format,["Pid", "Name", "MsgQ Len"]), + [begin + [{registered_name,Name},{message_queue_len,Len}] + = erlang:process_info(Pid, [registered_name, message_queue_len]), + io:format(Format,[to_list(Pid), to_list(Name), to_list(Len)]) + end || Pid <- processes()]; +eval("inspect " ++ PidStr) -> + case parse_pid(PidStr) of + invalid -> ok; + Pid -> + [{registered_name, Name}, {memory, Memory}, {messages, Messages}, {status, Status}] = + erlang:process_info(Pid, [registered_name, memory, messages, status]), + io:format("Pid: ~p~nName: ~ts~nStatus: ~p~nMemory: ~p~nMessages: ~p~n", + [Pid, to_list(Name), Status, Memory, Messages]) + end; +eval("suspend " ++ PidStr) -> + case parse_pid(PidStr) of + invalid -> ok; + Pid -> + erlang:suspend_process(Pid), + io:format("Suspeneded ~ts~n", [Pid]) + end; +eval("resume " ++ PidStr) -> + case parse_pid(PidStr) of + invalid -> ok; + Pid -> + erlang:resumne_process(Pid), + io:format("Resumed ~ts~n", [Pid]) + end; +eval(Unknown) -> + io:format("Unknown command: '~ts'~n",[Unknown]). + +to_list(Pid) when is_pid(Pid) -> + pid_to_list(Pid); +to_list(Atom) when is_atom(Atom) -> + atom_to_list(Atom); +to_list(Int) when is_integer(Int) -> + integer_to_list(Int); +to_list(List) when is_list(List) -> + List. + +parse_pid(PidStr) -> + try list_to_pid(PidStr) + catch _:_ -> io:format("Invalid pid format~n"), invalid + end. \ No newline at end of file diff --git a/lib/stdlib/doc/assets/tic-tac-toe.es b/lib/stdlib/doc/assets/tic-tac-toe.es new file mode 100755 index 000000000000..0fb694905ce2 --- /dev/null +++ b/lib/stdlib/doc/assets/tic-tac-toe.es @@ -0,0 +1,106 @@ +#!/usr/bin/env escript +main(_Args) -> + ok = shell:start_interactive({noshell, raw}), + + io:put_chars("\e[?1049h"), %% Enable alternate screen buffer + io:put_chars("\e[?25l"), %% Hide the cursor + draw_board(), + loop({0, "X", list_to_tuple(lists:duplicate(9, ""))}), + io:put_chars("\e[?25h"), %% Show the cursor + io:put_chars("\e[?1049l"), %% Disable alternate screen buffer + ok. + +draw_board() -> + io:put_chars("\e[5;0H"), %% Move cursor to top left + io:put_chars( + [" ╔═══════╤═══════╤═══════╗\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║ Place an X by pressing Enter\r\n", + " ║ │ │ ║\r\n", + " ╟───────┼───────┼───────╢\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ╟───────┼───────┼───────╢\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ╚═══════╧═══════╧═══════╝\r\n"]), + ok. + +loop(State) -> + io:put_chars(draw_state(State)), + case handle_input(io:get_chars("", 30), State) of + stop -> stop; + NewState -> + io:put_chars(clear_selection(State)), + loop(NewState) + end. + +%% Clear/draw the selection markers, making sure +%% not to overwrite if a X or O exists. +%% \b = Move cursor left +%% \e[C = Move cursor right +%% \n = Move cursor down +clear_selection({Pos, _, _}) -> + [set_position(Pos), + " ","\b\b\b\b\b\b\b\n", + " \e[C\e[C\e[C\e[C\e[C ", + "\b\b\b\b\b\b\b\n"," "]. + +draw_selection({Pos, _, _}) -> + [set_position(Pos), + "┌─────┐","\b\b\b\b\b\b\b\n", + "│\e[C\e[C\e[C\e[C\e[C│", + "\b\b\b\b\b\b\b\n","└─────┘"]. + +%% Set the cursor position to be at the top +%% left of the field of the given position +set_position(Pos) -> + Row = 6 + (Pos div 3) * 4, + Col = 7 + (Pos rem 3) * 8, + io_lib:format("\e[~p;~pH",[Row, Col]). + +%% Update selection and whos turn it is +draw_state({_, Turn, _} = State) -> + [draw_selection(State), + io_lib:format("\e[7;45H~s",[Turn])]. + +%% Draw X or O +draw_marker(Pos, Turn) -> + [set_position(Pos), "\e[C\e[C\e[C\n", Turn]. + +handle_input({ok, Chars}, State) -> + handle_input({ok, Chars}, State); +handle_input(eof, _State) -> + stop; +handle_input("\e[A" ++ Rest, {Pos, Turn, State}) -> + %% Up key + handle_input(Rest, {max(0, Pos - 3), Turn, State}); +handle_input("\e[B" ++ Rest, {Pos, Turn, State}) -> + %% Down key + handle_input(Rest, {min(8, Pos + 3), Turn, State}); +handle_input("\e[C" ++ Rest, {Pos, Turn, State}) -> + %% right key + handle_input(Rest, {min(8, Pos + 1), Turn, State}); +handle_input("\e[D" ++ Rest, {Pos, Turn, State}) -> + %% left key + handle_input(Rest, {max(0, Pos - 1), Turn, State}); +handle_input("\r" ++ Rest, {Pos, Turn, State} = OldState) -> + NewState = + case element(Pos+1, State) of + "" when Turn =:= "X" -> + io:put_chars(draw_marker(Pos, Turn)), + {Pos, "O", setelement(Pos+1, State, Turn)}; + "" when Turn =:= "O" -> + io:put_chars(draw_marker(Pos, Turn)), + {Pos, "X", setelement(Pos+1, State, Turn)}; + _ -> io:put_chars("\^G"), OldState + end, + handle_input(Rest, NewState); +handle_input("q" ++ _, _State) -> + stop; +handle_input([_ | T], State) -> + handle_input(T, State); +handle_input([], State) -> + State. \ No newline at end of file diff --git a/lib/stdlib/doc/docs.exs b/lib/stdlib/doc/docs.exs index 15957e70f56c..d3cb216f15b3 100644 --- a/lib/stdlib/doc/docs.exs +++ b/lib/stdlib/doc/docs.exs @@ -71,6 +71,8 @@ extras: [ "guides/introduction.md", "guides/io_protocol.md", + "guides/custom_shell.md", + "guides/terminal_interface.md", "guides/unicode_usage.md", "guides/uri_string_usage.md", "references/assert_hrl.md", diff --git a/lib/stdlib/doc/guides/custom_shell.md b/lib/stdlib/doc/guides/custom_shell.md new file mode 100644 index 000000000000..4e36d87d94cb --- /dev/null +++ b/lib/stdlib/doc/guides/custom_shell.md @@ -0,0 +1,220 @@ + +# Creating a custom shell + +This guide will show how to create a custom shell. The most common +use case for this is to support other languages running on the Erlang VM, +but it can also be used to create specialized debugging shells a system. + +This guide will build on top of the built-in [Erlang line editor](`m:edlin`), +which means that the keybindings described in [tty - A Command-Line Interface](`e:erts:tty.md`) +can be used edit the input before it is passed to the custom shell. This +somewhat limits what the custom shell can do, but it also means that we do not +have to implement line editing ourselves. If you need more control over the +shell, then use [Creating a terminal application](terminal_interface.md) as +a starting-point to build your own line editor and shell. + +## A process inspection shell + +The custom shell that we are going to build is a process inspection shell +that supports the following commands: + +* `list` - lists all processes +* `inspect pid()` - inspect a process +* `suspend pid()` - suspend a process +* `resume pid()` - resume a process + +Lets get started! + +## Starting with a custom shell + +The custom shell will be implemented in an `m:escript`, but it could just +as well be in a regular system or as a remote shell. To start a custom shell +we first need to start Erlang in `-noinput` or `-noshell` mode. `m:escript` are +started by default in `-noshell` mode, so we don't have to do anything special here. +To start the custom shell we then call `shell:start_interactive/1`. + +``` +#!/usr/bin/env escript +%% pshell.es +-export([start/0]). +main(_Args) -> + shell:start_interactive({?MODULE, start, []}), + timer:sleep(infinity). %% Make sure the escript does not exit + +-spec start() -> pid(). +start() -> + spawn(fun() -> + io:format(~"Starting process inspection shell~n"), + loop() + end). + +loop() -> + receive _M -> loop() end. +``` + +If we run the above we will get this: + +``` +$ ./pshell.es +Erlang/OTP 28 [DEVELOPMENT] [erts-15.0.1] [source-b395339a02] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns] + +Starting process inspection shell + +``` + +The `t:io:standard_io/0` of the created shell process will be set to the +Erlang line editor, which means that we can use the normal `m:io` functions +to read and write data to the terminal. + +## Adding our first command + +Let's start adding the shell interface. We will use `io:get_line/1` to read from +`t:io:standard_io/0` as this shell will be line based. However, for a more complex +shell it is better to send [`get_until` I/O requests](io_protocol.md#input-requests) +as commands read that way can span multiple lines. So we expand our `loop/0` with +a `io:get_line/1` and pass the results to our parser. + +``` +loop() -> + case io:get_line("> ") of + eof -> ok; + {error, Reason} -> exit(Reason); + Data -> eval(string:trim(Data)) + end, + loop(). + +eval("list") -> + Format = " ~.10ts | ~.10ts | ~.10ts~n", + io:format(Format,["Pid", "Name", "MsgQ Len"]), + [begin + [{registered_name,Name},{message_queue_len,Len}] + = erlang:process_info(Pid, [registered_name, message_queue_len]), + io:format(Format,[to_list(Pid), to_list(Name), to_list(Len)]) + end || Pid <- processes()]; +eval(Unknown) -> + io:format("Unknown command: '~ts'~n",[Unknown]). + +to_list(Pid) when is_pid(Pid) -> + pid_to_list(Pid); +to_list(Atom) when is_atom(Atom) -> + atom_to_list(Atom); +to_list(Int) when is_integer(Int) -> + integer_to_list(Int); +to_list(List) when is_list(List) -> + List. +``` + +If we run the above we will get this: + +```txt +$ ./pshell.es +Erlang/OTP 28 [DEVELOPMENT] [erts-15.0.1] [source-b395339a02] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns] + +Starting process inspection shell +> list + Pid | Name | MsgQ Len + <0.0.0> | init | 0 + <0.1.0> | erts_code_ | 0 + <0.2.0> | | 0 + <0.3.0> | | 0 + <0.4.0> | | 0 + <0.5.0> | | 0 + <0.6.0> | | 0 + <0.7.0> | | 0 + <0.8.0> | socket_reg | 0 + <0.10.0> | | 0 + <0.11.0> | erl_prim_l | 0 + <0.43.0> | logger | 0 + <0.45.0> | applicatio | 0 +... +``` + +With this all in place we can now easily add `inspect`, `suspend` and `resume` as well. + +``` +eval("inspect " ++ PidStr) -> + case parse_pid(PidStr) of + invalid -> ok; + Pid -> + [{registered_name, Name}, {memory, Memory}, {messages, Messages}, {status, Status}] = + erlang:process_info(Pid, [registered_name, memory, messages, status]), + io:format("Pid: ~p~nName: ~ts~nStatus: ~p~nMemory: ~p~nMessages: ~p~n", + [Pid, to_list(Name), Status, Memory, Messages]) + end; +eval("suspend " ++ PidStr) -> + case parse_pid(PidStr) of + invalid -> ok; + Pid -> + erlang:suspend_process(Pid), + io:format("Suspeneded ~ts~n") + end; +eval("resume " ++ PidStr) -> + case parse_pid(PidStr) of + invalid -> ok; + Pid -> + erlang:resumne_process(Pid), + io:format("Resumed ~ts~n") + end; +``` + +## Adding autocompletion + +Wouldn't it be great if we could add some simple auto-completion for our shell? We can do that +by setting a `m:edlin_expand` fun for our shell. This is done by calling [`io:setopts([{expand_fun, Fun}])`](`io:setopts/2`). The fun that we provide is will receive the reversed current line from +`m:edlin` and is expected to return possible expansions. Let's start by adding a simple fun to +expand our commands. + +``` +-spec start() -> pid(). +start() -> + spawn(fun() -> + io:setopts([{expand_fun, fun expand_fun/1}]), + io:format(~"Starting process inspection shell~n"), + loop() + end). + +-spec expand_fun(ReverseLine :: string()) -> {yes, string(), list(string())} | + {no, nil(), nil()}. +expand_fun("") -> %% If line is empty, we list all available commands + {yes, "", ["list", "inspect", "suspend", "resume"]}; +expand_fun(Curr) -> + expand_fun(lists:reverse(Curr), ["list", "inspect", "suspend", "resume"]). + +expand_fun(_Curr, []) -> + {no, "", []}; +expand_fun(Curr, [Cmd | T]) -> + case lists:prefix(Curr, Cmd) of + true -> + %% If Curr is a prefix of Cmd we subtract Curr from Cmd to get the + %% characters we need to complete with. + {yes, lists:reverse(lists:reverse(Cmd) -- lists:reverse(Curr)), []}; + false -> + expand_fun(Curr, T) + end. +``` + +With the above code we will get expansions of our commands if we hit `` in the shell. +Its possible to make very complex completion algorithms, for example the Erlang shell +has completions based on the function specifications of your code. It is important though that +the shell still feels responsive, so calling out to a LLM model for completion may or may not +be a good idea. + +The complete source code for this example can be found [here](assets/pshell.es). \ No newline at end of file diff --git a/lib/stdlib/doc/guides/terminal_interface.md b/lib/stdlib/doc/guides/terminal_interface.md new file mode 100644 index 000000000000..9cfb9e236657 --- /dev/null +++ b/lib/stdlib/doc/guides/terminal_interface.md @@ -0,0 +1,175 @@ + +# Creating a terminal application + +This guide will show how to create a very simple tic-tac-toe game in +the shell. We will go through how to read key-strokes and how to update +the screen to show the tic-tac-toe board. The game will be implemented as an +`m:escript`, but it can just as well be implemented in a regular system. + +Let us start by drawing the board which will look like this: + +```txt +╔═══════╤═══════╤═══════╗ +║┌─────┐│ │ ║ +║│ ││ │ ║ Place an X by pressing Enter +║└─────┘│ │ ║ +╟───────┼───────┼───────╢ +║ │ │ ║ +║ │ │ ║ +║ │ │ ║ +╟───────┼───────┼───────╢ +║ │ │ ║ +║ │ │ ║ +║ │ │ ║ +╚═══════╧═══════╧═══════╝ +``` +{: .monospace-font } + + +We will use the alternate screen buffer for our game so first we need to set that up: + +``` +#!/usr/bin/env escript +main(_Args) -> + + io:put_chars("\e[?1049h"), %% Enable alternate screen buffer + io:put_chars("\e[?25l"), %% Hide the cursor + draw_board(), + timer:sleep(5000), + io:put_chars("\e[?25h"), %% Show the cursor + io:put_chars("\e[?1049l"), %% Disable alternate screen buffer + ok. +``` + +We then use the box drawing parts of Unicode to draw our board: + +``` +draw_board() -> + io:put_chars("\e[5;0H"), %% Move cursor to top left + io:put_chars( + [" ╔═══════╤═══════╤═══════╗\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║ Place an X by pressing Enter\r\n", + " ║ │ │ ║\r\n", + " ╟───────┼───────┼───────╢\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ╟───────┼───────┼───────╢\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ║ │ │ ║\r\n", + " ╚═══════╧═══════╧═══════╝\r\n"]), + ok. +``` +{: .monospace-font } + +Let us add some interactivity to our game! To do that we need to change the +shell from running in `cooked` to `raw` mode. This is done by calling +[`shell:start_interactive({noshell, raw})`](`shell:start_interactive/1`). +We can then use `io:get_chars/2` to read key strokes from the user. The key +strokes will be returned as [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code), +so we will have need to handle the codes for up, down, left, right and enter. + +It could look something like this: + +``` +main(_Args) -> + ok = shell:start_interactive({noshell, raw}), + + io:put_chars("\e[?1049h"), %% Enable alternate screen buffer + io:put_chars("\e[?25l"), %% Hide the cursor + draw_board(), + loop(0), + io:put_chars("\e[?25h"), %% Show the cursor + io:put_chars("\e[?1049l"), %% Disable alternate screen buffer + ok. + +loop(Pos) -> + io:put_chars(draw_selection(Pos)), + %% Read at most 1024 characters from stdin. + {ok, Chars} = io:get_chars("", 1024), + case handle_input(Chars, Pos) of + stop -> stop; + NewPos -> + io:put_chars(clear_selection(Pos)), + loop(NewPos) + end. + +handle_input("\e[A" ++ Rest, Pos) -> + %% Up key + handle_input(Rest, max(0, Pos - 3)); +handle_input("\e[B" ++ Rest, Pos) -> + %% Down key + handle_input(Rest, min(8, Pos + 3)); +handle_input("\e[C" ++ Rest, Pos) -> + %% right key + handle_input(Rest, min(8, Pos + 1)); +handle_input("\e[D" ++ Rest, Pos) -> + %% left key + handle_input(Rest, max(0, Pos - 1)); +handle_input("q" ++ _, _State) -> + stop; +handle_input([_ | T], State) -> + handle_input(T, State); +handle_input([], State) -> + State. +``` + +Note that when using `io:get_chars/2` with the shell set in `{noshell, raw}` mode +it will return as soon as any data is available. The number of characters +is the maximum number that will be returned. We use 1024 here to make sure that +we always get all the data in one read. + +We also need to draw the selection marker, we do this using some simple drawing +routines. + +``` +%% Clear/draw the selection markers, making sure +%% not to overwrite if a X or O exists. +%% \b = Move cursor left +%% \e[C = Move cursor right +%% \n = Move cursor down +clear_selection(Pos) -> + [set_position(Pos), + " ","\b\b\b\b\b\b\b\n", + " \e[C\e[C\e[C\e[C\e[C ", + "\b\b\b\b\b\b\b\n"," "]. + +draw_selection(Pos) -> + [set_position(Pos), + "┌─────┐","\b\b\b\b\b\b\b\n", + "│\e[C\e[C\e[C\e[C\e[C│", + "\b\b\b\b\b\b\b\n","└─────┘"]. + +%% Set the cursor position to be at the top +%% left of the field of the given position +set_position(Pos) -> + Row = 6 + (Pos div 3) * 4, + Col = 7 + (Pos rem 3) * 8, + io_lib:format("\e[~p;~pH",[Row, Col]). +``` +{: #monospace-font } + +Now we have a program where we can move the marker around the board. +To complete the game we need to add some state so that we know which +squares are marked and whos turn it is. You can find the final solution +in [tic-tac-toe.es](assets/tic-tac-toe.es). \ No newline at end of file diff --git a/lib/stdlib/src/io_lib.erl b/lib/stdlib/src/io_lib.erl index 543496c34ca2..90e426ca0b79 100644 --- a/lib/stdlib/src/io_lib.erl +++ b/lib/stdlib/src/io_lib.erl @@ -86,9 +86,9 @@ used for flattening deep lists. printable_list/1, printable_latin1_list/1, printable_unicode_list/1]). %% Utilities for collecting characters mostly used by group --export([collect_chars/3, collect_chars/4, - collect_line/3, collect_line/4, collect_line_no_eol/4, - get_until/3, get_until/4]). +-export([collect_chars/1, collect_chars/3, collect_chars/4, collect_chars_eager/4, + collect_line/1, collect_line/3, collect_line/4, collect_line_no_eol/4, + get_until/1, get_until/3, get_until/4]). %% The following functions were used by Yecc's include-file. -export([write_unicode_string/1, write_unicode_char/1, @@ -1135,6 +1135,25 @@ collect_chars_list(Stack, N, []) -> collect_chars_list(Stack,N, [H|T]) -> collect_chars_list([H|Stack], N-1, T). +%% Fetch the number of remaining bytes +-doc false. +collect_chars({_, _, N}) -> + N. + +%% A special collect_chars that never returns more_chars, +%% instead it eagerly stops collecting if it has received +%% any characters at all. +-doc false. +collect_chars_eager(State, Chars, Encoding, N) -> + case collect_chars(State, Chars, Encoding, N) of + {list, Stack, _N} when Stack =/= [] -> + {stop, lists:reverse(Stack), []}; + {binary, Stack, _N} when Stack =/= [<<>>] -> + {stop, binrev(Stack), []}; + Else -> + Else + end. + %% collect_line(State, Data, _). New in R9C. %% Returns: %% {stop,Result,RestData} @@ -1206,6 +1225,11 @@ collect_line_list([H|T], Stack) -> collect_line_list([], Stack) -> Stack. +%% Return the number of remaing bytes, 0 for unknown. +-doc false. +collect_line(_State) -> + 0. + %% Translator function to emulate a new (R9C and later) %% I/O client when you have an old one. %% @@ -1262,6 +1286,11 @@ binrev(L) -> binrev(L, T) -> list_to_binary(lists:reverse(L, T)). +%% Return the number of remaing bytes, 0 for unknown. +-doc false. +get_until(_State) -> + 0. + -doc false. -spec limit_term(term(), depth()) -> term(). diff --git a/lib/stdlib/src/shell.erl b/lib/stdlib/src/shell.erl index 775fcf5219d7..8e5b5d1c40c3 100644 --- a/lib/stdlib/src/shell.erl +++ b/lib/stdlib/src/shell.erl @@ -65,6 +65,9 @@ non_local_allowed(_,_,State) -> Starts the interactive shell if it has not already been started. It can be used to programatically start the shell from an escript or when erl is started with the -noinput or -noshell flags. + +Calling this function will start a remote shell if `-remsh` is given on the +command line or a local shell if not. """. -doc(#{since => <<"OTP 26.0">>}). -spec start_interactive() -> ok | {error, already_started}. @@ -78,18 +81,30 @@ or when [`erl`](`e:erts:erl_cmd.md`) is started with the [`-noshell`](`e:erts:erl_cmd.md#noshell`) flags. The following options are allowed: -- **noshell** - Starts the interactive shell as if - [`-noshell`](`e:erts:erl_cmd.md#noshell`) was given to - [`erl`](`e:erts:erl_cmd.md`). This is only useful when erl is started with - [`-noinput`](`e:erts:erl_cmd.md#noinput`) and the system want to read input - data. +- **noshell | {noshell, Mode}** - Starts the interactive shell + as if [`-noshell`](`e:erts:erl_cmd.md#noshell`) was given to + [`erl`](`e:erts:erl_cmd.md`). + + It is possible to give a `Mode` indicating if the input should be set + in `cooked` or `raw` mode. `Mode` only has en effect if `t:io:user/0` is a tty. + If no `Mode` is given, it defaults is `cooked`. + + When in `raw` mode all key presses are passed to `t:io:user/0` as they are + typed when they are typed and the characters are not echoed to the terminal. + It is possible to set the `echo` to `true` using `io:setopts/2` to enabling + echoing again. + + When in `cooked` mode the OS will handle the line editing and all data is + passed to `t:io:user/0` when a newline is entered. - **[mfa()](`t:erlang:mfa/0`)** - Starts the interactive shell using - [`mfa()`](`t:erlang:mfa/0`) as the default shell. + [`mfa()`](`t:erlang:mfa/0`) as the default shell. The `t:mfa/0` should + return the `t:pid/0` of the created shell process. - **\{[node()](`t:erlang:node/0`), [mfa()](`t:erlang:mfa/0`)\}** - Starts the interactive shell using [`mfa()`](`t:erlang:mfa/0`) on - [`node()`](`t:erlang:node/0`) as the default shell. + [`node()`](`t:erlang:node/0`) as the default shell. The `t:mfa/0` should + return the `t:pid/0` of the created shell process. - **\{remote, [`string()`](`t:erlang:string/0`)\}** - Starts the interactive shell using as if [`-remsh`](`e:erts:erl_cmd.md#remsh`) was given to @@ -113,7 +128,7 @@ On error this function will return: description of the error reasons. """. -doc(#{since => <<"OTP 26.0">>}). --spec start_interactive(noshell | {module(), atom(), [term()]}) -> +-spec start_interactive(noshell | {noshell, raw | cooked} | {module(), atom(), [term()]}) -> ok | {error, already_started}; ({remote, string()}) -> ok | {error, already_started | noconnection}; @@ -121,6 +136,10 @@ On error this function will return: ok | {error, already_started | noconnection | badfile | nofile | on_load_failure}. start_interactive({Node, {M, F, A}}) -> user_drv:start_shell(#{ initial_shell => {Node, M, F ,A} }); +start_interactive(noshell) -> + start_interactive({noshell, cooked}); +start_interactive({noshell, Type}) when Type =:= raw; Type =:= cooked -> + user_drv:start_shell(#{ initial_shell => noshell, input => Type }); start_interactive(InitialShell) -> user_drv:start_shell(#{ initial_shell => InitialShell }). @@ -355,7 +374,7 @@ server_loop(N0, Eval_0, Bs00, RT, FT, Ds00, History0, Results0) -> [N]), server_loop(N0, Eval0, Bs0, RT, FT, Ds0, History0, Results0); eof -> - fwrite_severity(fatal, <<"Terminating erlang (~w)">>, [node()]), + catch fwrite_severity(fatal, <<"Terminating erlang (~w)">>, [node()]), halt() end. diff --git a/lib/stdlib/test/io_proto_SUITE.erl b/lib/stdlib/test/io_proto_SUITE.erl index 7c4b7d867ba5..a838f082ec41 100644 --- a/lib/stdlib/test/io_proto_SUITE.erl +++ b/lib/stdlib/test/io_proto_SUITE.erl @@ -316,19 +316,45 @@ setopts_getopts(Config) when is_list(Config) -> {expect, "true"} ],[],"",["-oldshell"]), - %% Test that terminal options when used in non-terminal - %% are returned as they should + %% Test that terminal options when used in non-terminal are returned as they should + %% both when run as an os:cmd and when run directly as a port. Erl = ct:get_progname(), - Str = os:cmd(Erl ++ " -noshell -eval \"io:format(~s'~p.',[io:getopts()])\" -s init stop"), + CmdStr = os:cmd(Erl ++ " -noshell -eval \"io:format(~s'~p.',[io:getopts()])\" -s init stop"), maybe - {ok, T, _} ?= erl_scan:string(Str), + {ok, T, _} ?= erl_scan:string(CmdStr), {ok, Opts} ?= erl_parse:parse_term(T), ?assertEqual(false, proplists:get_value(terminal,Opts)), - ?assertEqual(false, proplists:get_value(stdin,Opts)), + case os:type() of + {win32, nt} -> + %% On Windows stdin will be a tty + ?assertEqual(true, proplists:get_value(stdin,Opts)); + _ -> + ?assertEqual(false, proplists:get_value(stdin,Opts)) + end, ?assertEqual(false, proplists:get_value(stdout,Opts)), ?assertEqual(false, proplists:get_value(stderr,Opts)) else - _ -> ct:fail({failed_to_parse, Str}) + _ -> ct:fail({failed_to_parse, CmdStr}) + end, + + Port = erlang:open_port({spawn, Erl ++ " -noshell -eval \"io:format(~s'~p.',[io:getopts()])\" -s init stop"}, + [exit_status]), + PortStr = (fun F() -> + receive + {Port,{data,D}} -> D ++ F(); + {Port,{exit_status,0}} -> [] + end + end)(), + + maybe + {ok, PortT, _} ?= erl_scan:string(PortStr), + {ok, PortOpts} ?= erl_parse:parse_term(PortT), + ?assertEqual(false, proplists:get_value(terminal,PortOpts)), + ?assertEqual(false, proplists:get_value(stdin,PortOpts)), + ?assertEqual(false, proplists:get_value(stdout,PortOpts)), + ?assertEqual(proplists:get_value(stderr, io:getopts()), proplists:get_value(stderr,PortOpts)) + else + _ -> ct:fail({failed_to_parse, PortStr}) end, ok. diff --git a/make/ex_doc.exs b/make/ex_doc.exs index 9e2d71572ea4..bb19a1c7b8df 100644 --- a/make/ex_doc.exs +++ b/make/ex_doc.exs @@ -228,6 +228,9 @@ config = [ :epub -> """ + + """ end ]