From c36500615dc1b40cb55c0f6ebe272cfd49efbbef Mon Sep 17 00:00:00 2001 From: Leroy Hopson Date: Sun, 25 Feb 2024 16:30:39 +1300 Subject: [PATCH] feat(pty): further pty development --- addons/godot_xterm/native/src/constants.cpp | 132 +++++++++++- addons/godot_xterm/native/src/pty.cpp | 215 +++++++++++++++----- addons/godot_xterm/native/src/pty.h | 29 ++- test/test_unix.gd | 66 +----- 4 files changed, 332 insertions(+), 110 deletions(-) diff --git a/addons/godot_xterm/native/src/constants.cpp b/addons/godot_xterm/native/src/constants.cpp index fc67c8a..8f4fdaf 100644 --- a/addons/godot_xterm/native/src/constants.cpp +++ b/addons/godot_xterm/native/src/constants.cpp @@ -1,8 +1,10 @@ -// SPDX-FileCopyrightText: 2021, 2024 Leroy Hopson +// SPDX-FileCopyrightText: 2021, 2023-2024 Leroy Hopson // SPDX-License-Identifier: MIT +#include "pty.h" #include "terminal.h" +#include #include using namespace godot; @@ -194,3 +196,131 @@ const Terminal::KeyMap Terminal::KEY_MAP = { {{KEY_F34, '\0'}, XKB_KEY_F34}, {{KEY_F35, '\0'}, XKB_KEY_F35}, }; + +Error PTY::uv_err_to_godot_err(const int uv_err) { + if (uv_err >= 0) + return OK; + + // Rough translation of libuv error to godot error. + // Not necessarily accurate. + + switch (uv_err) { + case UV_EEXIST: // file already exists + return ERR_ALREADY_EXISTS; + + case UV_EADDRINUSE: // address already in use + return ERR_ALREADY_IN_USE; + + case UV_EBUSY: // resource busy or locked + case UV_ETXTBSY: // text file is busy + return ERR_BUSY; + + case UV_ECONNREFUSED: // connection refused + return ERR_CANT_CONNECT; + + case UV_ECONNABORTED: // software caused connection abort + case UV_ECONNRESET: // connection reset by peer + case UV_EISCONN: // socket is already connected + case UV_ENOTCONN: // socket is not connected + return ERR_CONNECTION_ERROR; + + case UV_ENODEV: // no such device + case UV_ENXIO: // no such device or address + case UV_ESRCH: // no such process + return ERR_DOES_NOT_EXIST; + + case UV_EROFS: // read-only file system + return ERR_FILE_CANT_WRITE; + + case UV_EOF: // end of file + return ERR_FILE_EOF; + + case UV_ENOENT: // no such file or directory + return ERR_FILE_NOT_FOUND; + + case UV_EAI_BADFLAGS: // bad ai_flags value + case UV_EAI_BADHINTS: // invalid value for hints + case UV_EFAULT: // bad address in system call argument + case UV_EFTYPE: // inappropriate file type or format + case UV_EINVAL: // invalid argument + case UV_ENOTTY: // inappropriate ioctl for device + case UV_EPROTOTYPE: // protocol wrong type for socket + return ERR_INVALID_PARAMETER; // Parameter passed is invalid + + case UV_ENOSYS: // function not implemented + return ERR_METHOD_NOT_FOUND; + + case UV_EAI_MEMORY: // out of memory + return ERR_OUT_OF_MEMORY; + + case UV_E2BIG: // argument list too long + case UV_EFBIG: // file too large + case UV_EMSGSIZE: // message too long + case UV_ENAMETOOLONG: // name too long + case UV_EOVERFLOW: // value too large for defined data type + case UV_ERANGE: // result too large + return ERR_PARAMETER_RANGE_ERROR; // Parameter given out of range + + case UV_ETIMEDOUT: + return ERR_TIMEOUT; // connection timed out + + case UV_EACCES: // permission denied + case UV_EPERM: // operation not permitted + case UV_EXDEV: // cross-device link not permitted + return ERR_UNAUTHORIZED; + + case UV_EADDRNOTAVAIL: // address not available + case UV_EAFNOSUPPORT: // address family not supported + case UV_EAGAIN: // resource temporarily unavailable + case UV_EAI_ADDRFAMILY: // address family not supported + case UV_EAI_FAMILY: // ai_family not supported + case UV_EAI_SERVICE: // service not available for socket type + case UV_EAI_SOCKTYPE: // socket type not supported + case UV_ENOPROTOOPT: // protocol not available + case UV_ENOTSUP: // operation not supported on socket + case UV_EPROTONOSUPPORT: // protocol not supported + case UV_ESOCKTNOSUPPORT: // socket type not supported + return ERR_UNAVAILABLE; // What is requested is + // unsupported/unavailable + + case UV_EAI_NODATA: // no address + case UV_EDESTADDRREQ: // destination address required + return ERR_UNCONFIGURED; + + case UV_EAI_AGAIN: // temporary failure + case UV_EAI_CANCELED: // request canceled + case UV_EAI_FAIL: // permanent failure + case UV_EAI_NONAME: // unknown node or service + case UV_EAI_OVERFLOW: // argument buffer overflow + case UV_EAI_PROTOCOL: // resolved protocol is unknown + case UV_EALREADY: // connection already in progress + case UV_EBADF: // bad file descriptor + case UV_ECANCELED: // operation canceled + case UV_ECHARSET: // invalid Unicode character + case UV_EHOSTUNREACH: // host is unreachable + case UV_EIO: // i/o error + case UV_EILSEQ: // illegal byte sequence + case UV_EISDIR: // illegal operation on a directory + case UV_ELOOP: // too many symbolic links encountered + case UV_EMFILE: // too many open files + case UV_ENETDOWN: // network is down + case UV_ENETUNREACH: // network is unreachable + case UV_ENFILE: // file table overflow + case UV_ENOBUFS: // no buffer space available + case UV_ENOMEM: // not enough memory + case UV_ESHUTDOWN: // cannot send after transport endpoint shutdown + case UV_EINTR: // interrupted system call + case UV_EMLINK: // too many links + case UV_ENONET: // machine is not on the network + case UV_ENOSPC: // no space left on device + case UV_ENOTDIR: // not a directory + case UV_ENOTEMPTY: // directory not empty + case UV_ENOTSOCK: // socket operation on non-socket + case UV_EPIPE: // broken pipe + case UV_EPROTO: // protocol error + case UV_ESPIPE: // invalid seek + case UV_UNKNOWN: // unknown error + default: + return FAILED; // Generic fail error + } +} diff --git a/addons/godot_xterm/native/src/pty.cpp b/addons/godot_xterm/native/src/pty.cpp index e2a922a..fdefc21 100644 --- a/addons/godot_xterm/native/src/pty.cpp +++ b/addons/godot_xterm/native/src/pty.cpp @@ -8,20 +8,71 @@ #include #endif +#define UV_ERR_MSG(uv_err) \ + String(uv_err_name(uv_err)) + String(": ") + String(uv_strerror(uv_err)) + +#define ERR_FAIL_UV_ERR(uv_err) \ + ERR_FAIL_COND_V_MSG(uv_err < 0, PTY::uv_err_to_godot_err(uv_err), \ + UV_ERR_MSG(uv_err)) + using namespace godot; +void _alloc_buffer(uv_handle_t *handle, size_t suggested_size, uv_buf_t *buf); +void _read_cb(uv_stream_t *pipe, ssize_t nread, const uv_buf_t *buf); +void _close_cb(uv_handle_t *handle); + +void PTY::_bind_methods() { + BIND_ENUM_CONSTANT(SIGNAL_SIGHUP); + BIND_ENUM_CONSTANT(SIGNAL_SIGINT); + BIND_ENUM_CONSTANT(SIGNAL_SIGQUIT); + BIND_ENUM_CONSTANT(SIGNAL_SIGILL); + BIND_ENUM_CONSTANT(SIGNAL_SIGTRAP); + BIND_ENUM_CONSTANT(SIGNAL_SIGABRT); + BIND_ENUM_CONSTANT(SIGNAL_SIGBUS); + BIND_ENUM_CONSTANT(SIGNAL_SIGFPE); + BIND_ENUM_CONSTANT(SIGNAL_SIGKILL); + BIND_ENUM_CONSTANT(SIGNAL_SIGUSR1); + BIND_ENUM_CONSTANT(SIGNAL_SIGSEGV); + BIND_ENUM_CONSTANT(SIGNAL_SIGUSR2); + BIND_ENUM_CONSTANT(SIGNAL_SIGPIPE); + BIND_ENUM_CONSTANT(SIGNAL_SIGALRM); + BIND_ENUM_CONSTANT(SIGNAL_SIGTERM); + + ADD_SIGNAL(MethodInfo("data_received", PropertyInfo(Variant::PACKED_BYTE_ARRAY, "data"))); + ADD_SIGNAL(MethodInfo("exited", PropertyInfo(Variant::INT, "exit_code"), PropertyInfo(Variant::INT, "signal_code"))); + + ClassDB::bind_method(D_METHOD("get_env"), &PTY::get_env); + ClassDB::bind_method(D_METHOD("set_env", "env"), &PTY::set_env); + ClassDB::add_property("PTY", PropertyInfo(Variant::DICTIONARY, "env"), "set_env", "get_env"); + + ClassDB::bind_method(D_METHOD("get_use_os_env"), &PTY::get_use_os_env); + ClassDB::bind_method(D_METHOD("set_use_os_env", "use_os_env"), &PTY::set_use_os_env); + ClassDB::add_property("PTY", PropertyInfo(Variant::BOOL, "use_os_env"), "set_use_os_env", "get_use_os_env"); + + ClassDB::bind_method(D_METHOD("get_pts"), &PTY::get_pts); + + ClassDB::bind_method(D_METHOD("fork", "file", "args", "cwd", "cols", "rows"), &PTY::fork, DEFVAL(""), DEFVAL(PackedStringArray()), DEFVAL("."), DEFVAL(80), DEFVAL(24)); + ClassDB::bind_method(D_METHOD("open", "cols", "rows"), &PTY::open, DEFVAL(80), DEFVAL(24)); + ClassDB::bind_method(D_METHOD("write", "data"), &PTY::write); + ClassDB::bind_method(D_METHOD("kill", "signal"), &PTY::kill); + + ClassDB::bind_method(D_METHOD("_on_exit", "exit_code", "signal_code"), &PTY::_on_exit); +} + PTY::PTY() { - os = OS::get_singleton(); + status = STATUS_NONE; env["TERM"] = "xterm-256color"; env["COLORTERM"] = "truecolor"; + + #if defined(__linux__) || defined(__APPLE__) + uv_pipe_init(uv_default_loop(), &pipe, false); + pipe.data = this; + #endif } PTY::~PTY() { -#if (defined(__linux__) || defined(__APPLE__)) && !defined(_PTY_DISABLED) - if (pid > 0) kill(SIGNAL_SIGHUP); - if (fd > 0) close(fd); -#endif + _close(); } int PTY::get_cols() const { @@ -48,19 +99,36 @@ void PTY::set_use_os_env(const bool value) { use_os_env = value; } +String PTY::get_pts() const { + return pts; +} + Error PTY::fork(const String &file, const PackedStringArray &args, const String &cwd, const int cols, const int rows) { String fork_file = _get_fork_file(file); Dictionary fork_env = _get_fork_env(); Dictionary result; #if defined(__linux__) || defined(__APPLE__) - String helper_path = ProjectSettings::get_singleton()->globalize_path("res://addons/godot_xterm/native/bin/spawn-helper"); - result = PTYUnix::fork(fork_file, args, PackedStringArray(), cwd, cols, rows, -1, -1, true, helper_path, Callable(this, "_on_exit")); - fd = result["fd"]; - pid = result["pid"]; + String helper_path = ProjectSettings::get_singleton()->globalize_path("res://addons/godot_xterm/native/bin/spawn-helper"); + result = PTYUnix::fork(fork_file, args, PackedStringArray(), cwd, cols, rows, -1, -1, true, helper_path, Callable(this, "_on_exit")); #endif - return static_cast((int)result["error"]); + Error err = static_cast((int)result["error"]); + ERR_FAIL_COND_V_MSG(err != OK, err, "Failed to fork."); + + fd = result["fd"]; + pid = result["pid"]; + pts = result["pty"]; + + # if defined(__linux__) || defined(__APPLE__) + err = _pipe_open(fd); + if (err != OK) { + status = STATUS_ERROR; + ERR_FAIL_V_MSG(err, "Failed to open pipe."); + } + #endif + + return OK; } void PTY::kill(const int signal) { @@ -71,14 +139,19 @@ void PTY::kill(const int signal) { #endif } -Error PTY::open(const int cols, const int rows) const { +Error PTY::open(const int cols, const int rows) { Dictionary result; #if defined(__linux__) || defined(__APPLE__) result = PTYUnix::open(cols, rows); #endif - return static_cast((int)result["error"]); + Error err = static_cast((int)result["error"]); + ERR_FAIL_COND_V(err != OK, err); + + pts = result["pty"]; + + return OK; } void PTY::resize(const int cols, const int rows) const { @@ -87,45 +160,39 @@ void PTY::resize(const int cols, const int rows) const { void PTY::write(const Variant &data) const { } -void PTY::_bind_methods() { - BIND_ENUM_CONSTANT(SIGNAL_SIGHUP); - BIND_ENUM_CONSTANT(SIGNAL_SIGINT); - BIND_ENUM_CONSTANT(SIGNAL_SIGQUIT); - BIND_ENUM_CONSTANT(SIGNAL_SIGILL); - BIND_ENUM_CONSTANT(SIGNAL_SIGTRAP); - BIND_ENUM_CONSTANT(SIGNAL_SIGABRT); - BIND_ENUM_CONSTANT(SIGNAL_SIGBUS); - BIND_ENUM_CONSTANT(SIGNAL_SIGFPE); - BIND_ENUM_CONSTANT(SIGNAL_SIGKILL); - BIND_ENUM_CONSTANT(SIGNAL_SIGUSR1); - BIND_ENUM_CONSTANT(SIGNAL_SIGSEGV); - BIND_ENUM_CONSTANT(SIGNAL_SIGUSR2); - BIND_ENUM_CONSTANT(SIGNAL_SIGPIPE); - BIND_ENUM_CONSTANT(SIGNAL_SIGALRM); - BIND_ENUM_CONSTANT(SIGNAL_SIGTERM); +void PTY::_process(double delta) { + #if defined(__linux__) || defined(__APPLE__) + if (status == STATUS_CONNECTED) { + if (!uv_is_active((uv_handle_t *)&pipe)) { + uv_read_start((uv_stream_t *)&pipe, _alloc_buffer, _read_cb); + } - ADD_SIGNAL(MethodInfo("exited", PropertyInfo(Variant::INT, "exit_code"), PropertyInfo(Variant::INT, "signal_code"))); - - ClassDB::bind_method(D_METHOD("get_env"), &PTY::get_env); - ClassDB::bind_method(D_METHOD("set_env", "env"), &PTY::set_env); - ClassDB::add_property("PTY", PropertyInfo(Variant::DICTIONARY, "env"), "set_env", "get_env"); - - ClassDB::bind_method(D_METHOD("get_use_os_env"), &PTY::get_use_os_env); - ClassDB::bind_method(D_METHOD("set_use_os_env", "use_os_env"), &PTY::set_use_os_env); - ClassDB::add_property("PTY", PropertyInfo(Variant::BOOL, "use_os_env"), "set_use_os_env", "get_use_os_env"); - - ClassDB::bind_method(D_METHOD("fork", "file", "args", "cwd", "cols", "rows"), &PTY::fork, DEFVAL(""), DEFVAL(PackedStringArray()), DEFVAL("."), DEFVAL(80), DEFVAL(24)); - ClassDB::bind_method(D_METHOD("open", "cols", "rows"), &PTY::open, DEFVAL(80), DEFVAL(24)); - ClassDB::bind_method(D_METHOD("write", "data"), &PTY::write); - ClassDB::bind_method(D_METHOD("kill", "signal"), &PTY::kill); - - ClassDB::bind_method(D_METHOD("_on_exit", "exit_code", "signal_code"), &PTY::_on_exit); + uv_run(uv_default_loop(), UV_RUN_NOWAIT); + } + #endif } +void PTY::_close() { + #if defined(__linux__) || defined(__APPLE__) + if (!uv_is_closing((uv_handle_t *)&pipe)) { + uv_close((uv_handle_t *)&pipe, _close_cb); + } + + uv_run(uv_default_loop(), UV_RUN_NOWAIT); + + if (fd > 0) close(fd); + if (pid > 0) kill(SIGNAL_SIGHUP); + + fd = -1; + pid = -1; + status = STATUS_NONE; + #endif +} + String PTY::_get_fork_file(const String &file) const { if (!file.is_empty()) return file; - String shell_env = os->get_environment("SHELL"); + String shell_env = OS::get_singleton()->get_environment("SHELL"); if (!shell_env.is_empty()) { return shell_env; } @@ -186,6 +253,62 @@ Dictionary PTY::_get_fork_env() const { } void PTY::_on_exit(int exit_code, int exit_signal) { - pid = -1; emit_signal(StringName("exited"), exit_code, exit_signal); } + +#if defined(__linux__) || defined(__APPLE__) + +void _alloc_buffer(uv_handle_t *handle, size_t suggested_size, uv_buf_t *buf) { + buf->base = (char *)malloc(suggested_size); + buf->len = suggested_size; +} + +void _read_cb(uv_stream_t *pipe, ssize_t nread, const uv_buf_t *buf) { + PTY *pty = static_cast(pipe->data); + + if (nread < 0) { + switch (nread) { + case UV_EOF: + // Normal after shell exits. + case UV_EIO: + // Can happen when the process exits. + // As long as PTY has caught it, we should be fine. + uv_read_stop(pipe); + pty->status = PTY::Status::STATUS_NONE; + return; + default: + pty->status = PTY::Status::STATUS_ERROR; + } + return; + } + + PackedByteArray data; + data.resize(nread); + memcpy(data.ptrw(), buf->base, nread); + std::free((char *)buf->base); + pty->call_deferred("emit_signal", "data_received", data); + + // Stop reading until the next poll, otherwise _read_cb could be called + // repeatedly, blocking Godot, and eventually resulting in a memory pool + // allocation error. This can be triggered with the command `cat /dev/urandom` + // if reading is not stopped. + uv_read_stop(pipe); +} + +void _close_cb(uv_handle_t *pipe) { + PTY *pty = static_cast(pipe->data); + pty->status = PTY::Status::STATUS_NONE; +} + +Error PTY::_pipe_open(const int fd) { + ERR_FAIL_COND_V_MSG(fd < 0, FAILED, "File descriptor must be a non-negative integer value."); + + ERR_FAIL_UV_ERR(uv_pipe_open(&pipe, fd)); + ERR_FAIL_UV_ERR(uv_stream_set_blocking((uv_stream_t *)&pipe, false)); + ERR_FAIL_UV_ERR(uv_read_start((uv_stream_t *)&pipe, _alloc_buffer, _read_cb)); + + status = STATUS_CONNECTED; + return OK; +} + +#endif diff --git a/addons/godot_xterm/native/src/pty.h b/addons/godot_xterm/native/src/pty.h index e01cee8..5a2dbbd 100644 --- a/addons/godot_xterm/native/src/pty.h +++ b/addons/godot_xterm/native/src/pty.h @@ -5,6 +5,7 @@ #include #include +#include namespace godot { @@ -31,9 +32,19 @@ namespace godot SIGNAL_SIGTERM = 15, }; + enum Status { + STATUS_NONE, + STATUS_CONNECTING, + STATUS_CONNECTED, + STATUS_PAUSED, + STATUS_ERROR, + }; + PTY(); ~PTY(); + Status status = STATUS_NONE; + int get_cols() const; int get_rows() const; @@ -43,19 +54,21 @@ namespace godot bool get_use_os_env() const; void set_use_os_env(const bool value); + String get_pts() const; + Error fork(const String &file = "", const PackedStringArray &args = PackedStringArray(), const String &cwd = ".", const int cols = 80, const int rows = 24); void kill(const int signum = Signal::SIGNAL_SIGHUP); - Error open(const int cols = 80, const int rows = 24) const; + Error open(const int cols = 80, const int rows = 24); void resize(const int cols, const int rows) const; void resizev(const Vector2i &size) const { resize(size.x, size.y); }; void write(const Variant &data) const; + void _process(double delta) override; + protected: static void _bind_methods(); private: - OS *os; - int pid = -1; int fd = -1; @@ -65,9 +78,19 @@ namespace godot Dictionary env = Dictionary(); bool use_os_env = true; + String pts = ""; + String _get_fork_file(const String &file) const; Dictionary _get_fork_env() const; void _on_exit(int exit_code, int exit_signal); + void _close(); + + #if defined(__linux__) || defined(__APPLE__) + uv_pipe_t pipe; + Error _pipe_open(const int fd); + #endif + + static Error uv_err_to_godot_err(const int uv_err); }; } // namespace godot diff --git a/test/test_unix.gd b/test/test_unix.gd index e6b4269..faa79ba 100644 --- a/test/test_unix.gd +++ b/test/test_unix.gd @@ -22,60 +22,10 @@ func test_fork_succeeds(): assert_eq(err, OK) -func xtest_fork_has_output(): - pty.call_deferred("fork", "exit") +func test_fork_emits_data_received(): + pty.call_deferred("fork", "sh", ["-c", "echo'"]) await wait_for_signal(pty.data_received, 1) - var expected := PackedByteArray( - [ - 101, - 120, - 101, - 99, - 118, - 112, - 40, - 51, - 41, - 32, - 102, - 97, - 105, - 108, - 101, - 100, - 46, - 58, - 32, - 78, - 111, - 32, - 115, - 117, - 99, - 104, - 32, - 102, - 105, - 108, - 101, - 32, - 111, - 114, - 32, - 100, - 105, - 114, - 101, - 99, - 116, - 111, - 114, - 121, - 13, - 10 - ] - ) - assert_signal_emitted_with_parameters(pty, "data_received", [expected]) + assert_signal_emitted(pty, "data_received") func test_open_succeeds(): @@ -90,13 +40,13 @@ func test_open_creates_a_new_pty(): assert_eq(new_num_pts, num_pts + 1) -func xtest_open_pty_has_correct_name(): +func test_open_pty_has_correct_name(): var original_pts = helper.get_pts() - var result = pty.open() + pty.open() var new_pts = helper.get_pts() for pt in original_pts: new_pts.erase(pt) - #assert_eq(result[1].pty, new_pts[0]) + assert_eq(pty.get_pts(), new_pts[0]) func xtest_open_pty_has_correct_win_size(): @@ -147,8 +97,6 @@ func test_emits_exit_code_on_failure(): func test_emits_exited_on_kill(): - if OS.get_name() == "macOS": - return pty.call("fork", "yes") await wait_frames(1) pty.call_deferred("kill", PTY.SIGNAL_SIGKILL) @@ -157,8 +105,6 @@ func test_emits_exited_on_kill(): func test_emits_exited_with_signal(): - if OS.get_name() == "macOS": - return pty.call("fork", "yes") await wait_frames(1) pty.call_deferred("kill", PTY.SIGNAL_SIGSEGV)