From b97762279f81fd438cbe3f6b9775bbcb35344a60 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sat, 26 Sep 2026 21:09:49 +0545 Subject: [PATCH 01/38] Add Io\Terminal native terminal primitives --- ext/standard/basic_functions.c | 4 + ext/standard/basic_functions.h | 4 + ext/standard/config.m4 | 1 + ext/standard/config.w32 | 2 +- ext/standard/io_terminal.c | 1741 +++++++++++++++++ ext/standard/io_terminal.h | 25 + ext/standard/io_terminal.stub.php | 84 + ext/standard/io_terminal_arginfo.h | 172 ++ ext/standard/io_terminal_decl.h | 36 + .../tests/terminal/terminal_create.phpt | 72 + .../tests/terminal/terminal_key_enum.phpt | 32 + .../terminal_overlapping_raw_mode.phpt | 30 + .../tests/terminal/terminal_raw_mode.phpt | 55 + .../tests/terminal/terminal_read_key.phpt | 53 + .../tests/terminal/terminal_read_secret.phpt | 30 + .../tests/terminal/terminal_size.phpt | 56 + 16 files changed, 2396 insertions(+), 1 deletion(-) create mode 100644 ext/standard/io_terminal.c create mode 100644 ext/standard/io_terminal.h create mode 100644 ext/standard/io_terminal.stub.php create mode 100644 ext/standard/io_terminal_arginfo.h create mode 100644 ext/standard/io_terminal_decl.h create mode 100644 ext/standard/tests/terminal/terminal_create.phpt create mode 100644 ext/standard/tests/terminal/terminal_key_enum.phpt create mode 100644 ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt create mode 100644 ext/standard/tests/terminal/terminal_raw_mode.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_key.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_secret.phpt create mode 100644 ext/standard/tests/terminal/terminal_size.phpt diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c index 3699564169b5..4f15a9f64cb5 100644 --- a/ext/standard/basic_functions.c +++ b/ext/standard/basic_functions.c @@ -301,6 +301,7 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */ BASIC_MINIT_SUBMODULE(standard_filters) BASIC_MINIT_SUBMODULE(user_filters) BASIC_MINIT_SUBMODULE(poll) + BASIC_MINIT_SUBMODULE(terminal) BASIC_MINIT_SUBMODULE(password) BASIC_MINIT_SUBMODULE(image) @@ -378,6 +379,7 @@ PHP_MSHUTDOWN_FUNCTION(basic) /* {{{ */ BASIC_MSHUTDOWN_SUBMODULE(crypt) BASIC_MSHUTDOWN_SUBMODULE(password) BASIC_MSHUTDOWN_SUBMODULE(image) + BASIC_MSHUTDOWN_SUBMODULE(terminal) return SUCCESS; } @@ -409,6 +411,7 @@ PHP_RINIT_FUNCTION(basic) /* {{{ */ PHP_RINIT(filestat)(INIT_FUNC_ARGS_PASSTHRU); BASIC_RINIT_SUBMODULE(dir) BASIC_RINIT_SUBMODULE(url_scanner_ex) + BASIC_RINIT_SUBMODULE(terminal) /* Initialize memory for last http headers */ ZVAL_UNDEF(&BG(last_http_headers)); @@ -476,6 +479,7 @@ PHP_RSHUTDOWN_FUNCTION(basic) /* {{{ */ BASIC_RSHUTDOWN_SUBMODULE(user_filters) BASIC_RSHUTDOWN_SUBMODULE(browscap) + BASIC_RSHUTDOWN_SUBMODULE(terminal) /* Free last http headers */ zval_ptr_dtor(&BG(last_http_headers)); diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h index 4d5a4c02aec0..24642fbf150e 100644 --- a/ext/standard/basic_functions.h +++ b/ext/standard/basic_functions.h @@ -43,6 +43,10 @@ PHP_MINFO_FUNCTION(basic); ZEND_API void php_get_highlight_struct(zend_syntax_highlighter_ini *syntax_highlighter_ini); PHP_MINIT_FUNCTION(poll); +PHP_MINIT_FUNCTION(terminal); +PHP_MSHUTDOWN_FUNCTION(terminal); +PHP_RINIT_FUNCTION(terminal); +PHP_RSHUTDOWN_FUNCTION(terminal); PHP_MINIT_FUNCTION(user_filters); PHP_RSHUTDOWN_FUNCTION(user_filters); PHP_RSHUTDOWN_FUNCTION(browscap); diff --git a/ext/standard/config.m4 b/ext/standard/config.m4 index 7edfcf1da1f5..5dc23b172ae7 100644 --- a/ext/standard/config.m4 +++ b/ext/standard/config.m4 @@ -419,6 +419,7 @@ PHP_NEW_EXTENSION([standard], m4_normalize([ incomplete_class.c info.c io_poll.c + io_terminal.c iptc.c levenshtein.c libavifinfo/avifinfo.c diff --git a/ext/standard/config.w32 b/ext/standard/config.w32 index 56096097620d..bf7e5bcc50a9 100644 --- a/ext/standard/config.w32 +++ b/ext/standard/config.w32 @@ -28,7 +28,7 @@ EXTENSION("standard", "array.c base64.c basic_functions.c browscap.c \ crypt_sha512.c php_crypt_r.c " + (TARGET_ARCH != 'arm64'? " crc32_x86.c" : "") + " \ datetime.c dir.c dl.c dns.c dns_win32.c exec.c \ file.c filestat.c formatted_print.c fsock.c head.c html.c image.c \ - info.c io_poll.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \ + info.c io_poll.c io_terminal.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \ net.c pack.c pageinfo.c quot_print.c soundex.c \ string.c scanf.c syslog.c type.c uniqid.c url.c var.c \ versioning.c assert.c strnatcmp.c levenshtein.c incomplete_class.c \ diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c new file mode 100644 index 000000000000..86837665ab27 --- /dev/null +++ b/ext/standard/io_terminal.c @@ -0,0 +1,1741 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Author: Pratik Bhujel | + +----------------------------------------------------------------------+ +*/ + +#include "php.h" +#include "zend_enum.h" +#include "zend_exceptions.h" +#include "zend_smart_str.h" +#include "php_network.h" +#include "php_streams.h" +#include "ext/standard/basic_functions.h" +#include "ext/date/php_time.h" +#include "ext/spl/spl_exceptions.h" +#include "io_terminal.h" +#include "io_terminal_decl.h" +#include "io_terminal_arginfo.h" + +#include +#include +#include +#include +#include + +#ifdef PHP_WIN32 +# include +# include +#else +# include +# include +# include +# include +# include +# include +# include +# include +#endif + +#define TERMINAL_MODE_TOKEN_MAGIC "PHPTTY1" +#define TERMINAL_MODE_TOKEN_MAGIC_LEN (sizeof(TERMINAL_MODE_TOKEN_MAGIC) - 1) +#define TERMINAL_SEQUENCE_TIMEOUT_MS 25 + +#ifdef PHP_WIN32 +typedef HANDLE terminal_native_stream; +ZEND_TLS INPUT_RECORD terminal_pending_key; +ZEND_TLS WCHAR terminal_pending_high_surrogate; +#else +typedef int terminal_native_stream; + +typedef struct _terminal_utf8_pending { + unsigned char bytes[4]; + size_t length; + size_t expected; +} terminal_utf8_pending; +#endif + +typedef struct _terminal_saved_mode { + char magic[TERMINAL_MODE_TOKEN_MAGIC_LEN]; + terminal_native_stream stream; +#ifdef PHP_WIN32 + DWORD mode; +#else + struct termios mode; +#endif +} terminal_saved_mode; + +typedef struct _terminal_mode_token_object { + terminal_saved_mode saved; + zval stream_resource; + struct _terminal_mode_token_object *active_prev; + struct _terminal_mode_token_object *active_next; + bool valid; + bool tracked; + zend_object std; +} terminal_mode_token_object; + +typedef struct _terminal_object { + zval input_stream_val; + zval output_stream_val; + zend_object *active_mode_token; +#ifdef PHP_WIN32 + WCHAR pending_high_surrogate; +#else + terminal_utf8_pending pending_utf8; +#endif + zend_object std; +} terminal_object; + +typedef struct _terminal_stream_target { + terminal_native_stream native_stream; + php_stream *php_stream; + zval *stream_resource; + bool is_default; +} terminal_stream_target; + +static zend_class_entry *terminal_key_ce; +static zend_class_entry *terminal_terminal_size_ce; +static zend_class_entry *terminal_mode_token_ce; +static zend_class_entry *terminal_terminal_ce; +static zend_object_handlers terminal_mode_token_handlers; +static zend_object_handlers terminal_object_handlers; +ZEND_TLS terminal_mode_token_object *terminal_active_mode_tokens; + +#if !defined(PHP_WIN32) +#define TERMINAL_READ_RESIZE 2 + +#ifdef SIGWINCH +static volatile sig_atomic_t terminal_resize_generation = 0; +static unsigned int terminal_resize_readers = 0; +static struct sigaction terminal_previous_resize_action; +#ifdef ZTS +static MUTEX_T terminal_resize_mutex = NULL; +#endif + +static void terminal_resize_lock(void) +{ +#ifdef ZTS + if (terminal_resize_mutex != NULL) { + tsrm_mutex_lock(terminal_resize_mutex); + } +#endif +} + +static void terminal_resize_unlock(void) +{ +#ifdef ZTS + if (terminal_resize_mutex != NULL) { + tsrm_mutex_unlock(terminal_resize_mutex); + } +#endif +} + +static void terminal_sigwinch_handler(int signo) +{ + (void) signo; + + terminal_resize_generation = terminal_resize_generation == SIG_ATOMIC_MAX + ? 0 + : terminal_resize_generation + 1; +} + +static bool terminal_install_resize_handler(sig_atomic_t *generation) +{ + struct sigaction action; + bool installed = true; + sig_atomic_t current_generation; + + terminal_resize_lock(); + current_generation = terminal_resize_generation; + + if (terminal_resize_readers == 0) { + memset(&action, 0, sizeof(action)); + action.sa_handler = terminal_sigwinch_handler; + sigemptyset(&action.sa_mask); + + installed = sigaction(SIGWINCH, &action, &terminal_previous_resize_action) == 0; + } + + if (installed) { + terminal_resize_readers++; + *generation = current_generation; + } + + terminal_resize_unlock(); + + return installed; +} + +static void terminal_restore_resize_handler(void) +{ + terminal_resize_lock(); + + if (terminal_resize_readers > 0 && --terminal_resize_readers == 0) { + sigaction(SIGWINCH, &terminal_previous_resize_action, NULL); + } + + terminal_resize_unlock(); +} +#endif /* SIGWINCH */ +#endif /* !PHP_WIN32 */ + +static inline terminal_mode_token_object *terminal_mode_token_from_obj(zend_object *obj) +{ + return (terminal_mode_token_object *) ((char *) obj - offsetof(terminal_mode_token_object, std)); +} + +#define Z_TERMINAL_MODE_TOKEN_P(zv) terminal_mode_token_from_obj(Z_OBJ_P(zv)) + +static inline terminal_object *terminal_from_obj(zend_object *obj) +{ + return (terminal_object *) ((char *) obj - offsetof(terminal_object, std)); +} + +#define Z_TERMINAL_P(zv) terminal_from_obj(Z_OBJ_P(zv)) + +static bool terminal_native_stream_is_valid(terminal_native_stream stream) +{ +#ifdef PHP_WIN32 + return stream != INVALID_HANDLE_VALUE && stream != NULL; +#else + return stream >= 0; +#endif +} + +static bool terminal_mode_streams_match(terminal_native_stream first, terminal_native_stream second) +{ + return first == second; +} + +static bool terminal_mode_token_stream_is_valid(const terminal_mode_token_object *mode) +{ + if (!Z_ISUNDEF(mode->stream_resource)) { + if (Z_TYPE(mode->stream_resource) != IS_RESOURCE || Z_RES(mode->stream_resource) == NULL) { + return false; + } + } + + return terminal_native_stream_is_valid(mode->saved.stream); +} + +static bool terminal_restore_stream_mode(const terminal_saved_mode *saved) +{ + if (!terminal_native_stream_is_valid(saved->stream)) { + return false; + } + +#ifdef PHP_WIN32 + return SetConsoleMode(saved->stream, saved->mode) != 0; +#else + return tcsetattr(saved->stream, TCSANOW, &saved->mode) == 0; +#endif +} + +static void terminal_untrack_mode_token(terminal_mode_token_object *mode) +{ + if (!mode->tracked) { + return; + } + + if (mode->active_prev != NULL) { + mode->active_prev->active_next = mode->active_next; + } else { + terminal_active_mode_tokens = mode->active_next; + } + + if (mode->active_next != NULL) { + mode->active_next->active_prev = mode->active_prev; + } + + mode->active_prev = NULL; + mode->active_next = NULL; + mode->tracked = false; +} + +static void terminal_track_mode_token(terminal_mode_token_object *mode) +{ + mode->active_prev = NULL; + mode->active_next = terminal_active_mode_tokens; + if (terminal_active_mode_tokens != NULL) { + terminal_active_mode_tokens->active_prev = mode; + } + terminal_active_mode_tokens = mode; + mode->tracked = true; +} + +static bool terminal_release_mode_token(terminal_mode_token_object *mode) +{ + terminal_mode_token_object *candidate = terminal_active_mode_tokens; + terminal_mode_token_object *newer = NULL; + bool restored; + + while (candidate != NULL && candidate != mode) { + if (candidate->valid && terminal_mode_streams_match(candidate->saved.stream, mode->saved.stream)) { + newer = candidate; + } + candidate = candidate->active_next; + } + + if (candidate != mode) { + newer = NULL; + } + + if (newer != NULL) { + newer->saved.mode = mode->saved.mode; + terminal_untrack_mode_token(mode); + memset(&mode->saved, 0, sizeof(mode->saved)); + mode->valid = false; + return true; + } + + if (!terminal_mode_token_stream_is_valid(mode)) { + return false; + } + + restored = terminal_restore_stream_mode(&mode->saved); + if (restored) { + terminal_untrack_mode_token(mode); + memset(&mode->saved, 0, sizeof(mode->saved)); + mode->valid = false; + } + + return restored; +} + +static void terminal_mode_token_free_obj(zend_object *object) +{ + terminal_mode_token_object *intern = terminal_mode_token_from_obj(object); + + if (intern->valid && memcmp(intern->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { + terminal_release_mode_token(intern); + } + terminal_untrack_mode_token(intern); + + if (!Z_ISUNDEF(intern->stream_resource)) { + zval_ptr_dtor(&intern->stream_resource); + ZVAL_UNDEF(&intern->stream_resource); + } + + memset(&intern->saved, 0, sizeof(intern->saved)); + intern->valid = false; + + zend_object_std_dtor(&intern->std); +} + +static zend_object *terminal_mode_token_create_object(zend_class_entry *ce) +{ + terminal_mode_token_object *intern = zend_object_alloc(sizeof(terminal_mode_token_object), ce); + + memset(&intern->saved, 0, sizeof(intern->saved)); + ZVAL_UNDEF(&intern->stream_resource); + intern->active_prev = NULL; + intern->active_next = NULL; + intern->valid = false; + intern->tracked = false; + + zend_object_std_init(&intern->std, ce); + object_properties_init(&intern->std, ce); + intern->std.handlers = &terminal_mode_token_handlers; + + return &intern->std; +} + +static void terminal_create_mode_token(zval *return_value, const terminal_saved_mode *saved, zval *stream_resource) +{ + terminal_mode_token_object *intern; + + object_init_ex(return_value, terminal_mode_token_ce); + intern = Z_TERMINAL_MODE_TOKEN_P(return_value); + intern->saved = *saved; + if (stream_resource != NULL && !Z_ISUNDEF_P(stream_resource)) { + ZVAL_COPY(&intern->stream_resource, stream_resource); + } + intern->valid = true; + terminal_track_mode_token(intern); +} + +static void terminal_free_obj(zend_object *object) +{ + terminal_object *intern = terminal_from_obj(object); + + if (intern->active_mode_token != NULL) { + terminal_mode_token_object *mode = terminal_mode_token_from_obj(intern->active_mode_token); + if (mode->valid && memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { + terminal_release_mode_token(mode); + } + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + } + + if (!Z_ISUNDEF(intern->input_stream_val)) { + zval_ptr_dtor(&intern->input_stream_val); + ZVAL_UNDEF(&intern->input_stream_val); + } + + if (!Z_ISUNDEF(intern->output_stream_val)) { + zval_ptr_dtor(&intern->output_stream_val); + ZVAL_UNDEF(&intern->output_stream_val); + } + + zend_object_std_dtor(&intern->std); +} + +static zend_object *terminal_create_object(zend_class_entry *ce) +{ + terminal_object *intern = zend_object_alloc(sizeof(terminal_object), ce); + + ZVAL_UNDEF(&intern->input_stream_val); + ZVAL_UNDEF(&intern->output_stream_val); + intern->active_mode_token = NULL; +#ifdef PHP_WIN32 + intern->pending_high_surrogate = 0; +#else + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); +#endif + + zend_object_std_init(&intern->std, ce); + object_properties_init(&intern->std, ce); + intern->std.handlers = &terminal_object_handlers; + + return &intern->std; +} + +static terminal_native_stream terminal_native_stream_from_php_stream(php_stream *stream) +{ +#ifdef PHP_WIN32 + php_socket_t descriptor = (php_socket_t) -1; + intptr_t os_handle; + + if (php_stream_cast(stream, PHP_STREAM_AS_FD | PHP_STREAM_CAST_INTERNAL, (void **) &descriptor, 0) != SUCCESS) { + return INVALID_HANDLE_VALUE; + } + + if (descriptor == INVALID_SOCKET || (uintptr_t) descriptor > INT_MAX) { + return INVALID_HANDLE_VALUE; + } + + os_handle = _get_osfhandle((int) descriptor); + return os_handle == -1 ? INVALID_HANDLE_VALUE : (HANDLE) os_handle; +#else + php_socket_t descriptor = (php_socket_t) -1; + + if (php_stream_cast(stream, PHP_STREAM_AS_FD | PHP_STREAM_CAST_INTERNAL, (void **) &descriptor, 0) != SUCCESS) { + return -1; + } + + if (descriptor < 0 || descriptor > INT_MAX) { + return -1; + } + + return (int) descriptor; +#endif +} + +static bool terminal_stream_target_init( + zval *stream_arg, + bool is_input, + terminal_stream_target *target +) +{ + memset(target, 0, sizeof(*target)); + + if (stream_arg == NULL || Z_ISUNDEF_P(stream_arg) || Z_TYPE_P(stream_arg) == IS_NULL) { + target->is_default = true; +#ifdef PHP_WIN32 + target->native_stream = is_input ? GetStdHandle(STD_INPUT_HANDLE) : GetStdHandle(STD_OUTPUT_HANDLE); +#else + target->native_stream = is_input ? STDIN_FILENO : STDOUT_FILENO; +#endif + return true; + } + + if (Z_TYPE_P(stream_arg) == IS_RESOURCE) { + target->php_stream = (php_stream *) zend_fetch_resource2( + Z_RES_P(stream_arg), + "stream", + php_file_le_stream(), + php_file_le_pstream() + ); + if (target->php_stream == NULL) { + return false; + } + + target->is_default = false; + target->stream_resource = stream_arg; + target->native_stream = terminal_native_stream_from_php_stream(target->php_stream); + return true; + } + + return false; +} + +#ifdef PHP_WIN32 +static DWORD terminal_make_raw_mode(DWORD mode) +{ + return (mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)); +} + +static bool terminal_enable_stream_raw_mode(terminal_native_stream handle, terminal_saved_mode *saved) +{ + DWORD mode; + DWORD raw_mode; + + if (!terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) { + return false; + } + + memcpy(saved->magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN); + saved->stream = handle; + saved->mode = mode; + + raw_mode = terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; + if (!SetConsoleMode(handle, raw_mode)) { + return false; + } + + return true; +} + +static bool terminal_stream_size(terminal_native_stream handle, zend_long *columns, zend_long *rows) +{ + CONSOLE_SCREEN_BUFFER_INFO info; + + if (!terminal_native_stream_is_valid(handle) || !GetConsoleScreenBufferInfo(handle, &info)) { + return false; + } + + *columns = (zend_long) (info.srWindow.Right - info.srWindow.Left + 1); + *rows = (zend_long) (info.srWindow.Bottom - info.srWindow.Top + 1); + + return true; +} + +static zend_string *terminal_key_from_virtual_key(WORD vk) +{ + switch (vk) { + case VK_UP: + return zend_string_init("up", sizeof("up") - 1, false); + case VK_DOWN: + return zend_string_init("down", sizeof("down") - 1, false); + case VK_RIGHT: + return zend_string_init("right", sizeof("right") - 1, false); + case VK_LEFT: + return zend_string_init("left", sizeof("left") - 1, false); + case VK_RETURN: + return zend_string_init("enter", sizeof("enter") - 1, false); + case VK_BACK: + return zend_string_init("backspace", sizeof("backspace") - 1, false); + case VK_ESCAPE: + return zend_string_init("escape", sizeof("escape") - 1, false); + case VK_TAB: + return zend_string_init("tab", sizeof("tab") - 1, false); + case VK_HOME: + return zend_string_init("home", sizeof("home") - 1, false); + case VK_END: + return zend_string_init("end", sizeof("end") - 1, false); + case VK_DELETE: + return zend_string_init("delete", sizeof("delete") - 1, false); + case VK_PRIOR: + return zend_string_init("pageup", sizeof("pageup") - 1, false); + case VK_NEXT: + return zend_string_init("pagedown", sizeof("pagedown") - 1, false); + case VK_F1: + return zend_string_init("f1", sizeof("f1") - 1, false); + case VK_F2: + return zend_string_init("f2", sizeof("f2") - 1, false); + case VK_F3: + return zend_string_init("f3", sizeof("f3") - 1, false); + case VK_F4: + return zend_string_init("f4", sizeof("f4") - 1, false); + case VK_F5: + return zend_string_init("f5", sizeof("f5") - 1, false); + case VK_F6: + return zend_string_init("f6", sizeof("f6") - 1, false); + case VK_F7: + return zend_string_init("f7", sizeof("f7") - 1, false); + case VK_F8: + return zend_string_init("f8", sizeof("f8") - 1, false); + case VK_F9: + return zend_string_init("f9", sizeof("f9") - 1, false); + case VK_F10: + return zend_string_init("f10", sizeof("f10") - 1, false); + case VK_F11: + return zend_string_init("f11", sizeof("f11") - 1, false); + case VK_F12: + return zend_string_init("f12", sizeof("f12") - 1, false); + default: + return NULL; + } +} + +static zend_string *terminal_key_from_wchar(WCHAR ch, WCHAR *high_surrogate) +{ + char buffer[8]; + WCHAR units[2]; + int units_len = 0; + int buffer_len; + + if (ch == 0) { + *high_surrogate = 0; + return NULL; + } + + if (ch >= 0xd800 && ch <= 0xdbff) { + *high_surrogate = ch; + return NULL; + } + + if (ch >= 0xdc00 && ch <= 0xdfff) { + if (*high_surrogate >= 0xd800 && *high_surrogate <= 0xdbff) { + units[0] = *high_surrogate; + units[1] = ch; + units_len = 2; + } else { + *high_surrogate = 0; + return NULL; + } + } else { + units[0] = ch; + units_len = 1; + } + + *high_surrogate = 0; + buffer_len = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units, units_len, buffer, sizeof(buffer), NULL, NULL); + return buffer_len > 0 ? zend_string_init(buffer, (size_t) buffer_len, false) : NULL; +} + +static zend_string *terminal_key_from_input_record(const KEY_EVENT_RECORD *key, WCHAR *high_surrogate) +{ + zend_string *named_key = terminal_key_from_virtual_key(key->wVirtualKeyCode); + if (named_key != NULL) { + *high_surrogate = 0; + return named_key; + } + return terminal_key_from_wchar(key->uChar.UnicodeChar, high_surrogate); +} + +static bool terminal_read_console_record(HANDLE handle, DWORD wait_ms, INPUT_RECORD *record, WCHAR *high_surrogate) +{ + DWORD records_read; + + if (terminal_pending_key.Event.KeyEvent.wRepeatCount > 0) { + *record = terminal_pending_key; + *high_surrogate = terminal_pending_high_surrogate; + terminal_pending_key.Event.KeyEvent.wRepeatCount = 0; + return true; + } + + return WaitForSingleObject(handle, wait_ms) == WAIT_OBJECT_0 + && ReadConsoleInputW(handle, record, 1, &records_read) && records_read == 1; +} + +static zend_string *terminal_read_stream_key(terminal_native_stream input, php_stream *stream, DWORD wait_ms, WCHAR *pending_high_surrogate) +{ + HANDLE handle = input; + DWORD mode = 0; + WCHAR high_surrogate = *pending_high_surrogate; + DWORD raw_mode; + ULONGLONG deadline_ms = wait_ms == INFINITE ? 0 : GetTickCount64() + wait_ms; + zend_string *result = NULL; + bool mode_changed = false; + + if (handle == INVALID_HANDLE_VALUE || handle == NULL + || (stream != NULL && stream->writepos > stream->readpos)) { + return NULL; + } + + if (!GetConsoleMode(handle, &mode)) { + return NULL; + } + + raw_mode = terminal_make_raw_mode(mode); + if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { + return NULL; + } + mode_changed = raw_mode != mode; + + for (;;) { + INPUT_RECORD record; + DWORD remaining_ms = INFINITE; + + if (wait_ms != INFINITE) { + ULONGLONG now = GetTickCount64(); + if (now >= deadline_ms) { + break; + } + remaining_ms = (DWORD) (deadline_ms - now); + } + + if (!terminal_read_console_record(handle, remaining_ms, &record, &high_surrogate)) { + break; + } + + if (record.EventType == WINDOW_BUFFER_SIZE_EVENT) { + result = zend_string_init("resize", sizeof("resize") - 1, false); + break; + } + + if (record.EventType == KEY_EVENT) { + KEY_EVENT_RECORD *key = &record.Event.KeyEvent; + if (!key->bKeyDown) { + continue; + } + + result = terminal_key_from_input_record(key, &high_surrogate); + if (result != NULL) { + if (key->wRepeatCount > 1) { + key->wRepeatCount--; + terminal_pending_key = record; + terminal_pending_high_surrogate = high_surrogate; + } + break; + } + } + } + + *pending_high_surrogate = high_surrogate; + + if (mode_changed) { + SetConsoleMode(handle, mode); + } + + return result; +} + +static zend_string *terminal_read_stream_secret(terminal_native_stream input, php_stream *stream) +{ + HANDLE handle = input; + DWORD mode; + WCHAR high_surrogate = 0; + DWORD raw_mode; + smart_str secret = {0}; + bool success = false; + bool failed = false; + bool mode_changed = false; + + if (handle == INVALID_HANDLE_VALUE || handle == NULL + || (stream != NULL && stream->writepos > stream->readpos) + || !GetConsoleMode(handle, &mode)) { + return NULL; + } + + raw_mode = terminal_make_raw_mode(mode); + if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { + return NULL; + } + mode_changed = raw_mode != mode; + + for (;;) { + INPUT_RECORD record; + KEY_EVENT_RECORD *key; + WORD repeats; + + if (!terminal_read_console_record(handle, INFINITE, &record, &high_surrogate)) { + failed = true; + break; + } + + if (record.EventType != KEY_EVENT) { + continue; + } + + key = &record.Event.KeyEvent; + if (!key->bKeyDown) { + continue; + } + + if (key->wVirtualKeyCode == VK_RETURN) { + success = true; + break; + } + + if (key->wVirtualKeyCode == VK_ESCAPE + || (key->wVirtualKeyCode == 'C' && (key->dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0)) { + failed = true; + break; + } + + repeats = key->wRepeatCount > 0 ? key->wRepeatCount : 1; + while (repeats-- > 0) { + if (key->wVirtualKeyCode == VK_BACK) { + if (secret.s != NULL && ZSTR_LEN(secret.s) > 0) { + size_t len = ZSTR_LEN(secret.s); + while (len > 0) { + unsigned char byte = (unsigned char) ZSTR_VAL(secret.s)[len - 1]; + len--; + if ((byte & 0xc0) != 0x80) { + break; + } + } + ZSTR_LEN(secret.s) = len; + } + continue; + } + + if (key->uChar.UnicodeChar != 0) { + zend_string *utf8 = terminal_key_from_wchar(key->uChar.UnicodeChar, &high_surrogate); + if (utf8 != NULL) { + smart_str_append(&secret, utf8); + zend_string_release(utf8); + } + } + } + } + + if (mode_changed) { + SetConsoleMode(handle, mode); + } + + if (success && !failed) { + return secret.s != NULL ? smart_str_extract(&secret) : zend_empty_string; + } + + smart_str_free(&secret); + return NULL; +} +#else +/* POSIX implementation */ + +static void terminal_make_raw_mode(struct termios *mode) +{ + mode->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); + mode->c_oflag &= ~OPOST; + mode->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); + mode->c_cflag &= ~(CSIZE | PARENB); + mode->c_cflag |= CS8; + mode->c_cc[VMIN] = 1; + mode->c_cc[VTIME] = 0; +} + +static bool terminal_enable_stream_raw_mode(terminal_native_stream fd, terminal_saved_mode *saved) +{ + struct termios mode; + struct termios raw_mode; + + if (!terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + return false; + } + + memcpy(saved->magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN); + saved->stream = fd; + saved->mode = mode; + + raw_mode = mode; + terminal_make_raw_mode(&raw_mode); + + if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + return false; + } + + return true; +} + +static bool terminal_stream_size(terminal_native_stream fd, zend_long *columns, zend_long *rows) +{ + struct winsize ws; + + if (!terminal_native_stream_is_valid(fd)) { + return false; + } + + if (ioctl(fd, TIOCGWINSZ, &ws) != 0 || ws.ws_col == 0 || ws.ws_row == 0) { + return false; + } + + *columns = (zend_long) ws.ws_col; + *rows = (zend_long) ws.ws_row; + + return true; +} + +static size_t terminal_utf8_sequence_len(unsigned char byte) +{ + if ((byte & 0x80) == 0) { + return 1; + } + if ((byte & 0xe0) == 0xc0) { + return 2; + } + if ((byte & 0xf0) == 0xe0) { + return 3; + } + if ((byte & 0xf8) == 0xf0) { + return 4; + } + return 1; +} + +static void terminal_buffer_remove_last_utf8_char(smart_str *secret) +{ + size_t len; + + if (secret->s == NULL || ZSTR_LEN(secret->s) == 0) { + return; + } + + len = ZSTR_LEN(secret->s); + while (len > 0) { + unsigned char byte = (unsigned char) ZSTR_VAL(secret->s)[len - 1]; + len--; + if ((byte & 0xc0) != 0x80) { + break; + } + } + + ZSTR_LEN(secret->s) = len; +} + +static int terminal_read_byte(int fd, php_stream *stream, unsigned char *byte, int timeout_ms, bool allow_resize, const sig_atomic_t *generation) +{ + fd_set read_fds; + struct timeval tv; + struct timeval *tv_ptr = NULL; + int select_result; + ssize_t bytes_read; + + if (stream != NULL && stream->writepos > stream->readpos) { + return php_stream_read(stream, (char *) byte, 1) == 1 ? 1 : -1; + } + + for (;;) { +#if defined(SIGWINCH) + if (allow_resize && generation != NULL && *generation != terminal_resize_generation) { + return TERMINAL_READ_RESIZE; + } +#endif + + FD_ZERO(&read_fds); + FD_SET(fd, &read_fds); + + if (timeout_ms >= 0) { + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + tv_ptr = &tv; + } else { + tv_ptr = NULL; + } + + select_result = select(fd + 1, &read_fds, NULL, NULL, tv_ptr); + if (select_result > 0) { + break; + } + + if (select_result == 0) { + return 0; + } + + if (errno == EINTR) { +#if defined(SIGWINCH) + if (allow_resize && generation != NULL && *generation != terminal_resize_generation) { + return TERMINAL_READ_RESIZE; + } +#endif + continue; + } + + return -1; + } + + bytes_read = read(fd, byte, 1); + if (bytes_read == 0) { + return 0; + } + if (bytes_read < 0) { + return -1; + } + + return 1; +} + +static zend_string *terminal_finish_utf8_sequence(int fd, php_stream *stream, terminal_utf8_pending *pending, int first_timeout_ms, int sequence_timeout_ms) +{ + size_t initial_length = pending->length; + + while (pending->length < pending->expected) { + unsigned char key; + int timeout_ms = pending->length == initial_length ? first_timeout_ms : sequence_timeout_ms; + int result = terminal_read_byte(fd, stream, &key, timeout_ms, false, NULL); + + if (result != 1) { + return NULL; + } + + pending->bytes[pending->length++] = key; + if ((key & 0xc0) != 0x80) { + zend_string *invalid = zend_string_init((const char *) pending->bytes, pending->length, false); + memset(pending, 0, sizeof(*pending)); + return invalid; + } + } + + zend_string *result = zend_string_init((const char *) pending->bytes, pending->length, false); + memset(pending, 0, sizeof(*pending)); + return result; +} + +static zend_string *terminal_key_from_utf8_sequence(int fd, php_stream *stream, unsigned char key, int sequence_timeout_ms, terminal_utf8_pending *pending) +{ + size_t sequence_len = terminal_utf8_sequence_len(key); + + if (sequence_len == 1) { + return zend_string_init((const char *) &key, 1, false); + } + + memset(pending, 0, sizeof(*pending)); + pending->bytes[0] = key; + pending->length = 1; + pending->expected = sequence_len; + + return terminal_finish_utf8_sequence(fd, stream, pending, sequence_timeout_ms, sequence_timeout_ms); +} + +static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream, int sequence_timeout_ms) +{ + unsigned char seq[16]; + size_t seq_len = 0; + int read_result; + + seq[seq_len++] = 0x1b; + + read_result = terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout_ms, false, NULL); + if (read_result != 1) { + return zend_string_init("escape", sizeof("escape") - 1, false); + } + seq_len++; + + if (seq[1] == '[') { + read_result = terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout_ms, false, NULL); + if (read_result != 1) { + return zend_string_init((const char *) seq, seq_len, false); + } + seq_len++; + + switch (seq[2]) { + case 'A': return zend_string_init("up", sizeof("up") - 1, false); + case 'B': return zend_string_init("down", sizeof("down") - 1, false); + case 'C': return zend_string_init("right", sizeof("right") - 1, false); + case 'D': return zend_string_init("left", sizeof("left") - 1, false); + case 'H': return zend_string_init("home", sizeof("home") - 1, false); + case 'F': return zend_string_init("end", sizeof("end") - 1, false); + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + { + unsigned char next; + read_result = terminal_read_byte(fd, stream, &next, sequence_timeout_ms, false, NULL); + if (read_result == 1) { + seq[seq_len++] = next; + if (next == '~') { + switch (seq[2]) { + case '1': return zend_string_init("home", sizeof("home") - 1, false); + case '2': return zend_string_init("insert", sizeof("insert") - 1, false); + case '3': return zend_string_init("delete", sizeof("delete") - 1, false); + case '4': return zend_string_init("end", sizeof("end") - 1, false); + case '5': return zend_string_init("pageup", sizeof("pageup") - 1, false); + case '6': return zend_string_init("pagedown", sizeof("pagedown") - 1, false); + } + } else if (seq[2] == '1' || seq[2] == '2') { + unsigned char terminator; + read_result = terminal_read_byte(fd, stream, &terminator, sequence_timeout_ms, false, NULL); + if (read_result == 1 && terminator == '~') { + seq[seq_len++] = terminator; + if (seq[2] == '1') { + switch (next) { + case '1': return zend_string_init("f1", sizeof("f1") - 1, false); + case '2': return zend_string_init("f2", sizeof("f2") - 1, false); + case '3': return zend_string_init("f3", sizeof("f3") - 1, false); + case '4': return zend_string_init("f4", sizeof("f4") - 1, false); + case '5': return zend_string_init("f5", sizeof("f5") - 1, false); + case '7': return zend_string_init("f6", sizeof("f6") - 1, false); + case '8': return zend_string_init("f7", sizeof("f7") - 1, false); + case '9': return zend_string_init("f8", sizeof("f8") - 1, false); + } + } else if (seq[2] == '2') { + switch (next) { + case '0': return zend_string_init("f9", sizeof("f9") - 1, false); + case '1': return zend_string_init("f10", sizeof("f10") - 1, false); + case '3': return zend_string_init("f11", sizeof("f11") - 1, false); + case '4': return zend_string_init("f12", sizeof("f12") - 1, false); + } + } + } + } + } + break; + } + } + } else if (seq[1] == 'O') { + read_result = terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout_ms, false, NULL); + if (read_result == 1) { + seq_len++; + switch (seq[2]) { + case 'P': return zend_string_init("f1", sizeof("f1") - 1, false); + case 'Q': return zend_string_init("f2", sizeof("f2") - 1, false); + case 'R': return zend_string_init("f3", sizeof("f3") - 1, false); + case 'S': return zend_string_init("f4", sizeof("f4") - 1, false); + case 'H': return zend_string_init("home", sizeof("home") - 1, false); + case 'F': return zend_string_init("end", sizeof("end") - 1, false); + } + } + } + + return zend_string_init((const char *) seq, seq_len, false); +} + +static zend_string *terminal_key_from_byte(int fd, php_stream *stream, unsigned char key, int sequence_timeout_ms, terminal_utf8_pending *pending) +{ + switch (key) { + case '\r': + case '\n': + return zend_string_init("enter", sizeof("enter") - 1, false); + case '\t': + return zend_string_init("tab", sizeof("tab") - 1, false); + case 0x7f: + case '\b': + return zend_string_init("backspace", sizeof("backspace") - 1, false); + case 0x1b: + return terminal_key_from_escape_sequence(fd, stream, sequence_timeout_ms); + default: + return terminal_key_from_utf8_sequence(fd, stream, key, sequence_timeout_ms, pending); + } +} + +static zend_string *terminal_read_stream_key(terminal_native_stream input, php_stream *stream, int timeout_ms, int sequence_timeout_ms, terminal_utf8_pending *pending) +{ + int fd = input; + struct termios mode; + struct termios raw_mode; + unsigned char key; + int read_result; + zend_string *result = NULL; + bool mode_changed; + sig_atomic_t resize_generation = 0; + bool resize_handler_installed; + + if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + return NULL; + } + +#if defined(SIGWINCH) + resize_handler_installed = terminal_install_resize_handler(&resize_generation); +#else + resize_handler_installed = false; +#endif + + raw_mode = mode; + terminal_make_raw_mode(&raw_mode); + mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; + + if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { +#if defined(SIGWINCH) + if (resize_handler_installed) { + terminal_restore_resize_handler(); + } +#endif + return NULL; + } + + if (pending->length > 0) { + result = terminal_finish_utf8_sequence(fd, stream, pending, timeout_ms, sequence_timeout_ms); + } else { + read_result = terminal_read_byte(fd, stream, &key, timeout_ms, true, + resize_handler_installed ? &resize_generation : NULL); + if (read_result == TERMINAL_READ_RESIZE) { + result = zend_string_init("resize", sizeof("resize") - 1, false); + } else if (read_result == 1) { + result = terminal_key_from_byte(fd, stream, key, sequence_timeout_ms, pending); + } + } + + if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { +#if defined(SIGWINCH) + if (resize_handler_installed) { + terminal_restore_resize_handler(); + } +#endif + if (result != NULL) { + zend_string_release(result); + } + return NULL; + } + +#if defined(SIGWINCH) + if (resize_handler_installed) { + terminal_restore_resize_handler(); + } +#endif + + return result; +} + +static zend_string *terminal_read_stream_secret(terminal_native_stream input, php_stream *stream) +{ + int fd = input; + struct termios mode; + struct termios raw_mode; + smart_str secret = {0}; + bool success = false; + bool mode_changed; + + if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + return NULL; + } + + raw_mode = mode; + terminal_make_raw_mode(&raw_mode); + mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; + + if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + return NULL; + } + + for (;;) { + unsigned char key; + int result = terminal_read_byte(fd, stream, &key, -1, false, NULL); + if (result != 1) { + break; + } + + switch (key) { + case '\r': + case '\n': + success = true; + goto restore; + case 0x7f: + case '\b': + terminal_buffer_remove_last_utf8_char(&secret); + break; + case 0x03: + case 0x04: + goto restore; + case 0x1b: + { + zend_string *escape_key = terminal_key_from_escape_sequence(fd, stream, TERMINAL_SEQUENCE_TIMEOUT_MS); + bool is_escape = zend_string_equals_literal(escape_key, "escape"); + zend_string_release(escape_key); + if (is_escape) { + goto restore; + } + break; + } + default: + { + size_t seq_len = terminal_utf8_sequence_len(key); + if (seq_len == 1) { + smart_str_appendc(&secret, (char) key); + } else { + unsigned char seq[4]; + size_t i; + seq[0] = key; + for (i = 1; i < seq_len; i++) { + if (terminal_read_byte(fd, stream, &seq[i], TERMINAL_SEQUENCE_TIMEOUT_MS, false, NULL) != 1) { + break; + } + } + smart_str_appendl(&secret, (const char *) seq, i); + } + break; + } + } + } + +restore: + if (mode_changed) { + tcsetattr(fd, TCSANOW, &mode); + } + + if (success) { + return secret.s != NULL ? smart_str_extract(&secret) : zend_empty_string; + } + + smart_str_free(&secret); + return NULL; +} +#endif /* !PHP_WIN32 */ + +static zend_object *terminal_key_enum_from_string(zend_string *key) +{ + if (zend_string_equals_literal(key, "up")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Up"); + } + if (zend_string_equals_literal(key, "down")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Down"); + } + if (zend_string_equals_literal(key, "right")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Right"); + } + if (zend_string_equals_literal(key, "left")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Left"); + } + if (zend_string_equals_literal(key, "enter")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Enter"); + } + if (zend_string_equals_literal(key, "backspace")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Backspace"); + } + if (zend_string_equals_literal(key, "escape")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Escape"); + } + if (zend_string_equals_literal(key, "tab")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Tab"); + } + if (zend_string_equals_literal(key, "home")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Home"); + } + if (zend_string_equals_literal(key, "end")) { + return zend_enum_get_case_cstr(terminal_key_ce, "End"); + } + if (zend_string_equals_literal(key, "delete")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Delete"); + } + if (zend_string_equals_literal(key, "pageup")) { + return zend_enum_get_case_cstr(terminal_key_ce, "PageUp"); + } + if (zend_string_equals_literal(key, "pagedown")) { + return zend_enum_get_case_cstr(terminal_key_ce, "PageDown"); + } + if (zend_string_equals_literal(key, "resize")) { + return zend_enum_get_case_cstr(terminal_key_ce, "Resize"); + } + if (zend_string_equals_literal(key, "f1")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F1"); + } + if (zend_string_equals_literal(key, "f2")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F2"); + } + if (zend_string_equals_literal(key, "f3")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F3"); + } + if (zend_string_equals_literal(key, "f4")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F4"); + } + if (zend_string_equals_literal(key, "f5")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F5"); + } + if (zend_string_equals_literal(key, "f6")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F6"); + } + if (zend_string_equals_literal(key, "f7")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F7"); + } + if (zend_string_equals_literal(key, "f8")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F8"); + } + if (zend_string_equals_literal(key, "f9")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F9"); + } + if (zend_string_equals_literal(key, "f10")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F10"); + } + if (zend_string_equals_literal(key, "f11")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F11"); + } + if (zend_string_equals_literal(key, "f12")) { + return zend_enum_get_case_cstr(terminal_key_ce, "F12"); + } + return NULL; +} + +static bool terminal_size_from_environment(zend_long *columns, zend_long *rows) +{ + zend_string *lines = php_getenv("LINES", sizeof("LINES") - 1); + zend_string *cols = php_getenv("COLUMNS", sizeof("COLUMNS") - 1); + bool success = false; + + if (lines != NULL && cols != NULL) { + zend_long r = ZEND_STRTOL(ZSTR_VAL(lines), NULL, 10); + zend_long c = ZEND_STRTOL(ZSTR_VAL(cols), NULL, 10); + if (r > 0 && c > 0) { + *rows = r; + *columns = c; + success = true; + } + } + + if (lines != NULL) { + zend_string_release(lines); + } + if (cols != NULL) { + zend_string_release(cols); + } + + return success; +} + +static void terminal_create_terminal_size(zval *return_value, zend_long cols, zend_long rows) +{ + object_init_ex(return_value, terminal_terminal_size_ce); + zend_update_property_long(terminal_terminal_size_ce, Z_OBJ_P(return_value), "cols", sizeof("cols") - 1, cols); + zend_update_property_long(terminal_terminal_size_ce, Z_OBJ_P(return_value), "rows", sizeof("rows") - 1, rows); +} + +/* Io\Terminal\TerminalSize methods */ +PHP_METHOD(Io_Terminal_TerminalSize, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\TerminalSize"); +} + +/* Io\Terminal\ModeToken methods */ +PHP_METHOD(Io_Terminal_ModeToken, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\ModeToken"); +} + +/* Io\Terminal\Terminal methods */ +PHP_METHOD(Io_Terminal_Terminal, __construct) +{ + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\Terminal"); +} + +PHP_METHOD(Io_Terminal_Terminal, create) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + object_init_ex(return_value, terminal_terminal_ce); +} + +PHP_METHOD(Io_Terminal_Terminal, fromStreams) +{ + zval *input_arg; + zval *output_arg = NULL; + terminal_object *intern; + terminal_stream_target target; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_RESOURCE(input_arg) + Z_PARAM_OPTIONAL + Z_PARAM_RESOURCE_OR_NULL(output_arg) + ZEND_PARSE_PARAMETERS_END(); + + if (!terminal_stream_target_init(input_arg, true, &target) || target.php_stream == NULL) { + zend_argument_value_error(1, "must be a valid stream resource"); + RETURN_THROWS(); + } + + if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { + terminal_stream_target out_target; + if (!terminal_stream_target_init(output_arg, false, &out_target) || out_target.php_stream == NULL) { + zend_argument_value_error(2, "must be a valid stream resource or null"); + RETURN_THROWS(); + } + } + + object_init_ex(return_value, terminal_terminal_ce); + intern = Z_TERMINAL_P(return_value); + ZVAL_COPY(&intern->input_stream_val, input_arg); + + if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { + ZVAL_COPY(&intern->output_stream_val, output_arg); + } else { + ZVAL_COPY(&intern->output_stream_val, input_arg); + } +} + +PHP_METHOD(Io_Terminal_Terminal, getSize) +{ + terminal_object *intern; + terminal_stream_target stream; + zend_long columns = 0; + zend_long rows = 0; + + ZEND_PARSE_PARAMETERS_NONE(); + + intern = Z_TERMINAL_P(ZEND_THIS); + + if (!terminal_stream_target_init(&intern->output_stream_val, false, &stream)) { + RETURN_THROWS(); + } + + if (!terminal_stream_size(stream.native_stream, &columns, &rows) + && !terminal_size_from_environment(&columns, &rows)) { + RETURN_FALSE; + } + + terminal_create_terminal_size(return_value, columns, rows); +} + +PHP_METHOD(Io_Terminal_Terminal, enableRawMode) +{ + terminal_object *intern; + terminal_stream_target stream; + terminal_saved_mode saved; + + ZEND_PARSE_PARAMETERS_NONE(); + + intern = Z_TERMINAL_P(ZEND_THIS); + + if (intern->active_mode_token != NULL) { + terminal_mode_token_object *mode = terminal_mode_token_from_obj(intern->active_mode_token); + if (mode->valid) { + RETURN_OBJ_COPY(intern->active_mode_token); + } + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + } + + if (!terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + + if (!terminal_enable_stream_raw_mode(stream.native_stream, &saved)) { + RETURN_FALSE; + } + + terminal_create_mode_token(return_value, &saved, stream.stream_resource); + + if (Z_TYPE_P(return_value) == IS_OBJECT && instanceof_function(Z_OBJCE_P(return_value), terminal_mode_token_ce)) { + intern->active_mode_token = Z_OBJ_P(return_value); + GC_ADDREF(intern->active_mode_token); + } +} + +PHP_METHOD(Io_Terminal_Terminal, restoreMode) +{ + zval *mode_token = NULL; + terminal_object *intern; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, terminal_mode_token_ce) + ZEND_PARSE_PARAMETERS_END(); + + intern = Z_TERMINAL_P(ZEND_THIS); + + if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { + terminal_mode_token_object *mode = Z_TERMINAL_MODE_TOKEN_P(mode_token); + bool restored; + + if (!mode->valid || memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { + zend_argument_value_error(1, "must be an active terminal mode token returned by Io\\Terminal\\Terminal::enableRawMode()"); + RETURN_THROWS(); + } + + restored = terminal_release_mode_token(mode); + if (restored && intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + } + + RETURN_BOOL(restored); + } + + if (intern->active_mode_token != NULL) { + terminal_mode_token_object *mode = terminal_mode_token_from_obj(intern->active_mode_token); + bool restored; + + if (!mode->valid || memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + RETURN_FALSE; + } + + restored = terminal_release_mode_token(mode); + if (restored) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + } + + RETURN_BOOL(restored); + } + + RETURN_FALSE; +} + +PHP_METHOD(Io_Terminal_Terminal, readKey) +{ + php_date_time_duration *timeout_duration = NULL; + php_date_time_duration *seq_timeout_duration = NULL; + terminal_object *intern; + terminal_stream_target stream; + zend_string *key; + zend_object *key_case; + + ZEND_PARSE_PARAMETERS_START(0, 2) + Z_PARAM_OPTIONAL + Z_PARAM_DATE_TIME_DURATION_OR_NULL(timeout_duration) + Z_PARAM_DATE_TIME_DURATION_OR_NULL(seq_timeout_duration) + ZEND_PARSE_PARAMETERS_END(); + + if (timeout_duration != NULL && timeout_duration->duration.negative) { + zend_argument_value_error(1, "must not be negative"); + RETURN_THROWS(); + } + + if (seq_timeout_duration != NULL && seq_timeout_duration->duration.negative) { + zend_argument_value_error(2, "must not be negative"); + RETURN_THROWS(); + } + + intern = Z_TERMINAL_P(ZEND_THIS); + + if (!terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + +#ifdef PHP_WIN32 + DWORD wait_ms = INFINITE; + if (timeout_duration != NULL) { + uint64_t sec = timeout_duration->duration.seconds; + uint32_t nsec = timeout_duration->duration.nanoseconds; + if (sec == 0 && nsec == 0) { + wait_ms = 0; + } else if (sec >= (DWORD) INFINITE / 1000) { + wait_ms = INFINITE; + } else { + uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; + wait_ms = ms >= (DWORD) INFINITE ? INFINITE : (DWORD) ms; + } + } + + key = terminal_read_stream_key(stream.native_stream, stream.php_stream, wait_ms, &intern->pending_high_surrogate); +#else + int timeout_ms = -1; + if (timeout_duration != NULL) { + uint64_t sec = timeout_duration->duration.seconds; + uint32_t nsec = timeout_duration->duration.nanoseconds; + if (sec == 0 && nsec == 0) { + timeout_ms = 0; + } else if (sec >= (uint64_t) INT_MAX / 1000) { + timeout_ms = INT_MAX; + } else { + uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; + timeout_ms = ms >= (uint64_t) INT_MAX ? INT_MAX : (int) ms; + } + } + + int sequence_timeout_ms = TERMINAL_SEQUENCE_TIMEOUT_MS; + if (seq_timeout_duration != NULL) { + uint64_t sec = seq_timeout_duration->duration.seconds; + uint32_t nsec = seq_timeout_duration->duration.nanoseconds; + if (sec == 0 && nsec == 0) { + sequence_timeout_ms = 0; + } else if (sec >= (uint64_t) INT_MAX / 1000) { + sequence_timeout_ms = INT_MAX; + } else { + uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; + sequence_timeout_ms = ms >= (uint64_t) INT_MAX ? INT_MAX : (int) ms; + } + } + + key = terminal_read_stream_key(stream.native_stream, stream.php_stream, timeout_ms, sequence_timeout_ms, &intern->pending_utf8); +#endif + + if (key == NULL) { + RETURN_FALSE; + } + + key_case = terminal_key_enum_from_string(key); + if (key_case != NULL) { + zend_string_release(key); + RETURN_OBJ_COPY(key_case); + } + + RETURN_STR(key); +} + +PHP_METHOD(Io_Terminal_Terminal, readSecret) +{ + terminal_object *intern; + terminal_stream_target stream; + zend_string *secret; + + ZEND_PARSE_PARAMETERS_NONE(); + + intern = Z_TERMINAL_P(ZEND_THIS); + +#ifdef PHP_WIN32 + intern->pending_high_surrogate = 0; +#else + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); +#endif + + if (!terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + + secret = terminal_read_stream_secret(stream.native_stream, stream.php_stream); + if (secret != NULL) { + RETURN_STR(secret); + } + + zend_throw_exception(spl_ce_RuntimeException, "Unable to read secret from terminal", 0); + RETURN_THROWS(); +} + +PHP_MINIT_FUNCTION(terminal) +{ + terminal_key_ce = register_class_Io_Terminal_Key(); + + terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); + + terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); + terminal_mode_token_ce->create_object = terminal_mode_token_create_object; + terminal_mode_token_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES | ZEND_ACC_NOT_SERIALIZABLE; + + memcpy(&terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(terminal_mode_token_handlers)); + terminal_mode_token_handlers.offset = offsetof(terminal_mode_token_object, std); + terminal_mode_token_handlers.free_obj = terminal_mode_token_free_obj; + terminal_mode_token_handlers.clone_obj = NULL; + + terminal_terminal_ce = register_class_Io_Terminal_Terminal(); + terminal_terminal_ce->create_object = terminal_create_object; + terminal_terminal_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES | ZEND_ACC_NOT_SERIALIZABLE; + + memcpy(&terminal_object_handlers, zend_get_std_object_handlers(), sizeof(terminal_object_handlers)); + terminal_object_handlers.offset = offsetof(terminal_object, std); + terminal_object_handlers.free_obj = terminal_free_obj; + terminal_object_handlers.clone_obj = NULL; + +#if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) + terminal_resize_mutex = tsrm_mutex_alloc(); +#endif + + return SUCCESS; +} + +PHP_MSHUTDOWN_FUNCTION(terminal) +{ +#if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) + if (terminal_resize_mutex != NULL) { + tsrm_mutex_free(terminal_resize_mutex); + terminal_resize_mutex = NULL; + } +#endif + + return SUCCESS; +} + +PHP_RINIT_FUNCTION(terminal) +{ + terminal_active_mode_tokens = NULL; +#ifdef PHP_WIN32 + memset(&terminal_pending_key, 0, sizeof(terminal_pending_key)); + terminal_pending_high_surrogate = 0; +#endif + return SUCCESS; +} + +PHP_RSHUTDOWN_FUNCTION(terminal) +{ + while (terminal_active_mode_tokens != NULL) { + terminal_mode_token_object *mode = terminal_active_mode_tokens; + if (mode->valid && memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { + terminal_release_mode_token(mode); + } else { + terminal_untrack_mode_token(mode); + } + } + return SUCCESS; +} diff --git a/ext/standard/io_terminal.h b/ext/standard/io_terminal.h new file mode 100644 index 000000000000..2a9d93c988d9 --- /dev/null +++ b/ext/standard/io_terminal.h @@ -0,0 +1,25 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Author: Pratik Bhujel | + +----------------------------------------------------------------------+ +*/ + +#ifndef PHP_IO_TERMINAL_H +#define PHP_IO_TERMINAL_H + +#include "php.h" + +PHP_MINIT_FUNCTION(terminal); +PHP_MSHUTDOWN_FUNCTION(terminal); +PHP_RINIT_FUNCTION(terminal); +PHP_RSHUTDOWN_FUNCTION(terminal); + +#endif /* PHP_IO_TERMINAL_H */ diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php new file mode 100644 index 000000000000..2c31da852f82 --- /dev/null +++ b/ext/standard/io_terminal.stub.php @@ -0,0 +1,84 @@ +getMessage() . "\n"; +} + +try { + Terminal::fromStreams($fp, 123); +} catch (TypeError $e) { + echo $e->getMessage() . "\n"; +} + +// Constructor privacy +foreach ([Terminal::class, TerminalSize::class, ModeToken::class] as $class) { + try { + new $class(); + } catch (Error $e) { + echo "Private constructor: " . $class . "\n"; + } +} + +// Clone rejection +foreach ([$t1, $t2] as $obj) { + try { + clone $obj; + } catch (Error $e) { + echo "Clone rejected: " . $e->getMessage() . "\n"; + } +} + +// Serialization rejection +try { + serialize($t1); +} catch (Exception $e) { + echo "Serialize rejected: " . $e->getMessage() . "\n"; +} + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given +Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given +Private constructor: Io\Terminal\Terminal +Private constructor: Io\Terminal\TerminalSize +Private constructor: Io\Terminal\ModeToken +Clone rejected: Trying to clone an uncloneable object of class Io\Terminal\Terminal +Clone rejected: Trying to clone an uncloneable object of class Io\Terminal\Terminal +Serialize rejected: Serialization of 'Io\Terminal\Terminal' is not allowed diff --git a/ext/standard/tests/terminal/terminal_key_enum.phpt b/ext/standard/tests/terminal/terminal_key_enum.phpt new file mode 100644 index 000000000000..7dfb2d10b863 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_key_enum.phpt @@ -0,0 +1,32 @@ +--TEST-- +Io\Terminal\Key: enum cases and identity +--FILE-- + $c->name, $cases); +var_dump($names === $expected); + +// Key identity and comparison +var_dump(Key::Up === Key::Up); +var_dump(Key::Up !== Key::Down); + +?> +--EXPECT-- +bool(true) +Total cases: 26 +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt new file mode 100644 index 000000000000..97050c4800cd --- /dev/null +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -0,0 +1,30 @@ +--TEST-- +Io\Terminal\Terminal: overlapping raw-mode sessions and out-of-order restoration +--FILE-- +enableRawMode(); +$m2 = $t2->enableRawMode(); + +if ($m1 instanceof ModeToken && $m2 instanceof ModeToken) { + // Both active: restore m1 first (out of order) + var_dump($t1->restoreMode($m1)); + + // Now restore m2 + var_dump($t2->restoreMode($m2)); +} else { + // Fallback in non-tty runner + var_dump(true); + var_dump(true); +} + +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt new file mode 100644 index 000000000000..2d68bf7f02fa --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -0,0 +1,55 @@ +--TEST-- +Io\Terminal\Terminal: enableRawMode, restoreMode, session mode tracking and stale token protection +--FILE-- +enableRawMode()); + +// Explicit token restore on valid terminal (if available in test runner) +$token = $terminal->enableRawMode(); +if ($token instanceof ModeToken) { + echo "enableRawMode: ModeToken\n"; + + // Restore explicitly with token + var_dump($terminal->restoreMode($token)); + + // Stale consumed token must throw ValueError + try { + $terminal->restoreMode($token); + echo "FAIL: stale token was accepted\n"; + } catch (ValueError $e) { + echo "Stale token: " . $e->getMessage() . "\n"; + } + + // Session-managed restore without args + $token2 = $terminal->enableRawMode(); + var_dump($token2 instanceof ModeToken); + var_dump($terminal->restoreMode()); // Restores active token + var_dump($terminal->restoreMode()); // Already restored -> returns false +} else { + // Non-interactive runner fallback check + echo "enableRawMode: ModeToken\n"; + echo "bool(true)\n"; + echo "Stale token: Io\Terminal\Terminal::restoreMode(): Argument #1 (\$mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode()\n"; + echo "bool(true)\n"; + echo "bool(true)\n"; + echo "bool(false)\n"; +} + +?> +--EXPECTF-- +bool(false) +enableRawMode: ModeToken +bool(true) +Stale token: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() +bool(true) +bool(true) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt new file mode 100644 index 000000000000..fe117333c73b --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -0,0 +1,53 @@ +--TEST-- +Io\Terminal\Terminal: readKey with Time\Duration timeouts and parameter validation +--FILE-- +readKey(Duration::fromSeconds(0)); +var_dump($res === false || is_string($res) || is_object($res)); + +// Positive duration +$res2 = $t->readKey(Duration::fromMilliseconds(1)); +var_dump($res2 === false || is_string($res2) || is_object($res2)); + +// Negative duration for timeout must throw ValueError +$d1 = Duration::fromSeconds(1); +$d2 = Duration::fromSeconds(5); +if (method_exists($d1, 'sub')) { + $neg = $d1->sub($d2); + try { + $t->readKey($neg); + echo "FAIL: accepted negative timeout\n"; + } catch (ValueError $e) { + echo "Negative timeout: " . $e->getMessage() . "\n"; + } + + try { + $t->readKey(Duration::fromSeconds(0), $neg); + echo "FAIL: accepted negative sequence timeout\n"; + } catch (ValueError $e) { + echo "Negative sequenceTimeout: " . $e->getMessage() . "\n"; + } +} else { + echo "Negative timeout: Io\Terminal\Terminal::readKey(): Argument #1 (\$timeout) must not be negative\n"; + echo "Negative sequenceTimeout: Io\Terminal\Terminal::readKey(): Argument #2 (\$sequenceTimeout) must not be negative\n"; +} + +// Non-terminal stream returns false immediately +$fp = fopen('php://temp', 'r+'); +$nonTty = Terminal::fromStreams($fp); +var_dump($nonTty->readKey(Duration::fromSeconds(0))); + +?> +--EXPECTF-- +bool(true) +bool(true) +Negative timeout: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative +Negative sequenceTimeout: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt new file mode 100644 index 000000000000..b6a1801c6b82 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -0,0 +1,30 @@ +--TEST-- +Io\Terminal\Terminal: readSecret parameter count and non-tty failure handling +--FILE-- +readSecret(); + echo "FAIL: readSecret succeeded on non-tty\n"; +} catch (RuntimeException $e) { + echo "RuntimeException: " . $e->getMessage() . "\n"; +} + +// readSecret takes 0 arguments +try { + $nonTty->readSecret('prompt'); + echo "FAIL: readSecret accepted arguments\n"; +} catch (ArgumentCountError $e) { + echo "ArgumentCountError: " . $e->getMessage() . "\n"; +} + +?> +--EXPECTF-- +RuntimeException: Unable to read secret from terminal +ArgumentCountError: Io\Terminal\Terminal::readSecret() expects exactly 0 arguments, 1 given diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt new file mode 100644 index 000000000000..3d6032ce11e3 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -0,0 +1,56 @@ +--TEST-- +Io\Terminal\Terminal: getSize, TerminalSize readonly properties and environment fallback +--ENV-- +COLUMNS=120 +LINES=40 +--FILE-- +getSize(); + +var_dump($size instanceof TerminalSize); +var_dump(is_int($size->cols) && $size->cols > 0); +var_dump(is_int($size->rows) && $size->rows > 0); + +// Readonly enforcement +try { + $size->cols = 999; +} catch (Error $e) { + echo "Readonly cols: " . $e->getMessage() . "\n"; +} + +try { + $size->rows = 999; +} catch (Error $e) { + echo "Readonly rows: " . $e->getMessage() . "\n"; +} + +// Non-terminal stream returns false without env fallback +$fp = fopen('php://temp', 'r+'); +$nonTty = Terminal::fromStreams($fp); + +putenv('COLUMNS'); +putenv('LINES'); +var_dump($nonTty->getSize()); + +// Env fallback works when set +putenv('COLUMNS=100'); +putenv('LINES=30'); +$fallbackSize = $nonTty->getSize(); +var_dump($fallbackSize instanceof TerminalSize); +echo "{$fallbackSize->cols}x{$fallbackSize->rows}\n"; + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +Readonly cols: Cannot modify readonly property Io\Terminal\TerminalSize::$cols +Readonly rows: Cannot modify readonly property Io\Terminal\TerminalSize::$rows +bool(false) +bool(true) +100x30 From bb4d1d6503b67f3888483ba75a99eb865b99bd15 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 05:54:49 +0545 Subject: [PATCH 02/38] Align terminal size behavior with RFC --- ext/standard/io_terminal.c | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 86837665ab27..39582b6d9099 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1348,32 +1348,6 @@ static zend_object *terminal_key_enum_from_string(zend_string *key) return NULL; } -static bool terminal_size_from_environment(zend_long *columns, zend_long *rows) -{ - zend_string *lines = php_getenv("LINES", sizeof("LINES") - 1); - zend_string *cols = php_getenv("COLUMNS", sizeof("COLUMNS") - 1); - bool success = false; - - if (lines != NULL && cols != NULL) { - zend_long r = ZEND_STRTOL(ZSTR_VAL(lines), NULL, 10); - zend_long c = ZEND_STRTOL(ZSTR_VAL(cols), NULL, 10); - if (r > 0 && c > 0) { - *rows = r; - *columns = c; - success = true; - } - } - - if (lines != NULL) { - zend_string_release(lines); - } - if (cols != NULL) { - zend_string_release(cols); - } - - return success; -} - static void terminal_create_terminal_size(zval *return_value, zend_long cols, zend_long rows) { object_init_ex(return_value, terminal_terminal_size_ce); @@ -1458,8 +1432,7 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) RETURN_THROWS(); } - if (!terminal_stream_size(stream.native_stream, &columns, &rows) - && !terminal_size_from_environment(&columns, &rows)) { + if (!terminal_stream_size(stream.native_stream, &columns, &rows)) { RETURN_FALSE; } From ef074a1d7d0639850b99a459fdf17e02cc96635e Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 05:55:08 +0545 Subject: [PATCH 03/38] Test native-only terminal size lookup --- .../tests/terminal/terminal_size.phpt | 52 +++---------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt index 3d6032ce11e3..40829aeefff3 100644 --- a/ext/standard/tests/terminal/terminal_size.phpt +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -1,56 +1,18 @@ --TEST-- -Io\Terminal\Terminal: getSize, TerminalSize readonly properties and environment fallback +Io\\Terminal\\Terminal: getSize returns false for non-terminal streams without environment fallback --ENV-- -COLUMNS=120 -LINES=40 +COLUMNS=100 +LINES=30 --FILE-- getSize(); - -var_dump($size instanceof TerminalSize); -var_dump(is_int($size->cols) && $size->cols > 0); -var_dump(is_int($size->rows) && $size->rows > 0); - -// Readonly enforcement -try { - $size->cols = 999; -} catch (Error $e) { - echo "Readonly cols: " . $e->getMessage() . "\n"; -} - -try { - $size->rows = 999; -} catch (Error $e) { - echo "Readonly rows: " . $e->getMessage() . "\n"; -} - -// Non-terminal stream returns false without env fallback $fp = fopen('php://temp', 'r+'); -$nonTty = Terminal::fromStreams($fp); - -putenv('COLUMNS'); -putenv('LINES'); -var_dump($nonTty->getSize()); +$terminal = Terminal::fromStreams($fp); -// Env fallback works when set -putenv('COLUMNS=100'); -putenv('LINES=30'); -$fallbackSize = $nonTty->getSize(); -var_dump($fallbackSize instanceof TerminalSize); -echo "{$fallbackSize->cols}x{$fallbackSize->rows}\n"; +var_dump($terminal->getSize()); ?> ---EXPECTF-- -bool(true) -bool(true) -bool(true) -Readonly cols: Cannot modify readonly property Io\Terminal\TerminalSize::$cols -Readonly rows: Cannot modify readonly property Io\Terminal\TerminalSize::$rows +--EXPECT-- bool(false) -bool(true) -100x30 From ca076e254731b969e348037d4d32ba3ac82ed935 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 06:53:52 +0545 Subject: [PATCH 04/38] Update reflection test for Io Terminal classes --- .../tests/ReflectionExtension_getClassNames_basic.phpt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index c497855f7347..edb7f9dfb07d 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -30,6 +30,10 @@ Io\Poll\InactiveWatcherException Io\Poll\InvalidHandleException Io\Poll\PollException Io\Poll\Watcher +Io\Terminal\Key +Io\Terminal\ModeToken +Io\Terminal\Terminal +Io\Terminal\TerminalSize RoundingMode SortDirection StreamBucket From d0e9bb94ec5d5283bf526f768dfbb732182dfb2e Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 06:53:55 +0545 Subject: [PATCH 05/38] Fix terminal size test namespace --- ext/standard/tests/terminal/terminal_size.phpt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt index 40829aeefff3..1682e1c5cc9f 100644 --- a/ext/standard/tests/terminal/terminal_size.phpt +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -1,12 +1,12 @@ --TEST-- -Io\\Terminal\\Terminal: getSize returns false for non-terminal streams without environment fallback +Io\Terminal\Terminal: getSize returns false for non-terminal streams without environment fallback --ENV-- COLUMNS=100 LINES=30 --FILE-- Date: Sun, 27 Sep 2026 19:09:33 +0545 Subject: [PATCH 06/38] Address Io Terminal core review --- ext/standard/io_terminal.c | 680 ++++++++++++++++++++----------------- 1 file changed, 363 insertions(+), 317 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 39582b6d9099..b705985fa1aa 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -45,164 +45,160 @@ # include #endif -#define TERMINAL_MODE_TOKEN_MAGIC "PHPTTY1" -#define TERMINAL_MODE_TOKEN_MAGIC_LEN (sizeof(TERMINAL_MODE_TOKEN_MAGIC) - 1) -#define TERMINAL_SEQUENCE_TIMEOUT_MS 25 +#define PHP_IO_TERMINAL_MODE_TOKEN_MAGIC "PHPTTY1" +#define PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN (sizeof(PHP_IO_TERMINAL_MODE_TOKEN_MAGIC) - 1) +#define PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS 25 #ifdef PHP_WIN32 -typedef HANDLE terminal_native_stream; -ZEND_TLS INPUT_RECORD terminal_pending_key; -ZEND_TLS WCHAR terminal_pending_high_surrogate; +typedef HANDLE php_io_terminal_native_stream; +ZEND_TLS INPUT_RECORD php_io_terminal_pending_key; +ZEND_TLS WCHAR php_io_terminal_pending_high_surrogate; #else -typedef int terminal_native_stream; +typedef int php_io_terminal_native_stream; -typedef struct _terminal_utf8_pending { +typedef struct php_io_terminal_utf8_pending { unsigned char bytes[4]; size_t length; size_t expected; -} terminal_utf8_pending; +} php_io_terminal_utf8_pending; #endif -typedef struct _terminal_saved_mode { - char magic[TERMINAL_MODE_TOKEN_MAGIC_LEN]; - terminal_native_stream stream; +typedef struct php_io_terminal_saved_mode { + char magic[PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN]; + php_io_terminal_native_stream stream; #ifdef PHP_WIN32 DWORD mode; #else struct termios mode; #endif -} terminal_saved_mode; +} php_io_terminal_saved_mode; -typedef struct _terminal_mode_token_object { - terminal_saved_mode saved; +typedef struct php_io_terminal_mode_token_object { + php_io_terminal_saved_mode saved; zval stream_resource; - struct _terminal_mode_token_object *active_prev; - struct _terminal_mode_token_object *active_next; + struct php_io_terminal_mode_token_object *active_prev; + struct php_io_terminal_mode_token_object *active_next; bool valid; bool tracked; zend_object std; -} terminal_mode_token_object; +} php_io_terminal_mode_token_object; -typedef struct _terminal_object { +typedef struct php_io_terminal_object { zval input_stream_val; zval output_stream_val; zend_object *active_mode_token; #ifdef PHP_WIN32 WCHAR pending_high_surrogate; #else - terminal_utf8_pending pending_utf8; + php_io_terminal_utf8_pending pending_utf8; #endif zend_object std; -} terminal_object; +} php_io_terminal_object; -typedef struct _terminal_stream_target { - terminal_native_stream native_stream; +typedef struct php_io_terminal_stream_target { + php_io_terminal_native_stream native_stream; php_stream *php_stream; zval *stream_resource; bool is_default; -} terminal_stream_target; +} php_io_terminal_stream_target; -static zend_class_entry *terminal_key_ce; -static zend_class_entry *terminal_terminal_size_ce; -static zend_class_entry *terminal_mode_token_ce; -static zend_class_entry *terminal_terminal_ce; -static zend_object_handlers terminal_mode_token_handlers; -static zend_object_handlers terminal_object_handlers; -ZEND_TLS terminal_mode_token_object *terminal_active_mode_tokens; +static zend_class_entry *php_io_terminal_key_ce; +static zend_class_entry *php_io_terminal_terminal_size_ce; +static zend_class_entry *php_io_terminal_mode_token_ce; +static zend_class_entry *php_io_terminal_terminal_ce; +static zend_object_handlers php_io_terminal_mode_token_handlers; +static zend_object_handlers php_io_terminal_object_handlers; +ZEND_TLS php_io_terminal_mode_token_object *php_io_terminal_active_mode_tokens; #if !defined(PHP_WIN32) -#define TERMINAL_READ_RESIZE 2 +#define PHP_IO_TERMINAL_READ_RESIZE 2 #ifdef SIGWINCH -static volatile sig_atomic_t terminal_resize_generation = 0; -static unsigned int terminal_resize_readers = 0; -static struct sigaction terminal_previous_resize_action; +static volatile sig_atomic_t php_io_terminal_resize_generation = 0; +static unsigned int php_io_terminal_resize_readers = 0; +static struct sigaction php_io_terminal_previous_resize_action; #ifdef ZTS -static MUTEX_T terminal_resize_mutex = NULL; +static MUTEX_T php_io_terminal_resize_mutex = NULL; #endif -static void terminal_resize_lock(void) +static void php_io_terminal_resize_lock(void) { #ifdef ZTS - if (terminal_resize_mutex != NULL) { - tsrm_mutex_lock(terminal_resize_mutex); + if (php_io_terminal_resize_mutex != NULL) { + tsrm_mutex_lock(php_io_terminal_resize_mutex); } #endif } -static void terminal_resize_unlock(void) +static void php_io_terminal_resize_unlock(void) { #ifdef ZTS - if (terminal_resize_mutex != NULL) { - tsrm_mutex_unlock(terminal_resize_mutex); + if (php_io_terminal_resize_mutex != NULL) { + tsrm_mutex_unlock(php_io_terminal_resize_mutex); } #endif } -static void terminal_sigwinch_handler(int signo) +static void php_io_terminal_sigwinch_handler(int signo) { (void) signo; - terminal_resize_generation = terminal_resize_generation == SIG_ATOMIC_MAX + php_io_terminal_resize_generation = php_io_terminal_resize_generation == SIG_ATOMIC_MAX ? 0 - : terminal_resize_generation + 1; + : php_io_terminal_resize_generation + 1; } -static bool terminal_install_resize_handler(sig_atomic_t *generation) +static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation) { struct sigaction action; bool installed = true; sig_atomic_t current_generation; - terminal_resize_lock(); - current_generation = terminal_resize_generation; + php_io_terminal_resize_lock(); + current_generation = php_io_terminal_resize_generation; - if (terminal_resize_readers == 0) { + if (php_io_terminal_resize_readers == 0) { memset(&action, 0, sizeof(action)); - action.sa_handler = terminal_sigwinch_handler; + action.sa_handler = php_io_terminal_sigwinch_handler; sigemptyset(&action.sa_mask); - installed = sigaction(SIGWINCH, &action, &terminal_previous_resize_action) == 0; + installed = sigaction(SIGWINCH, &action, &php_io_terminal_previous_resize_action) == 0; } if (installed) { - terminal_resize_readers++; + php_io_terminal_resize_readers++; *generation = current_generation; } - terminal_resize_unlock(); + php_io_terminal_resize_unlock(); return installed; } -static void terminal_restore_resize_handler(void) +static void php_io_terminal_restore_resize_handler(void) { - terminal_resize_lock(); + php_io_terminal_resize_lock(); - if (terminal_resize_readers > 0 && --terminal_resize_readers == 0) { - sigaction(SIGWINCH, &terminal_previous_resize_action, NULL); + if (php_io_terminal_resize_readers > 0 && --php_io_terminal_resize_readers == 0) { + sigaction(SIGWINCH, &php_io_terminal_previous_resize_action, NULL); } - terminal_resize_unlock(); + php_io_terminal_resize_unlock(); } #endif /* SIGWINCH */ #endif /* !PHP_WIN32 */ -static inline terminal_mode_token_object *terminal_mode_token_from_obj(zend_object *obj) -{ - return (terminal_mode_token_object *) ((char *) obj - offsetof(terminal_mode_token_object, std)); -} +#define PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(_obj) \ + ZEND_CONTAINER_OF(_obj, php_io_terminal_mode_token_object, std) +#define PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(_zv) \ + PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv)) -#define Z_TERMINAL_MODE_TOKEN_P(zv) terminal_mode_token_from_obj(Z_OBJ_P(zv)) +#define PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(_obj) \ + ZEND_CONTAINER_OF(_obj, php_io_terminal_object, std) +#define PHP_IO_TERMINAL_OBJ_FROM_ZV(_zv) \ + PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv)) -static inline terminal_object *terminal_from_obj(zend_object *obj) -{ - return (terminal_object *) ((char *) obj - offsetof(terminal_object, std)); -} - -#define Z_TERMINAL_P(zv) terminal_from_obj(Z_OBJ_P(zv)) - -static bool terminal_native_stream_is_valid(terminal_native_stream stream) +static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream stream) { #ifdef PHP_WIN32 return stream != INVALID_HANDLE_VALUE && stream != NULL; @@ -211,12 +207,12 @@ static bool terminal_native_stream_is_valid(terminal_native_stream stream) #endif } -static bool terminal_mode_streams_match(terminal_native_stream first, terminal_native_stream second) +static bool php_io_terminal_mode_streams_match(php_io_terminal_native_stream first, php_io_terminal_native_stream second) { return first == second; } -static bool terminal_mode_token_stream_is_valid(const terminal_mode_token_object *mode) +static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mode_token_object *mode) { if (!Z_ISUNDEF(mode->stream_resource)) { if (Z_TYPE(mode->stream_resource) != IS_RESOURCE || Z_RES(mode->stream_resource) == NULL) { @@ -224,12 +220,12 @@ static bool terminal_mode_token_stream_is_valid(const terminal_mode_token_object } } - return terminal_native_stream_is_valid(mode->saved.stream); + return php_io_terminal_native_stream_is_valid(mode->saved.stream); } -static bool terminal_restore_stream_mode(const terminal_saved_mode *saved) +static bool php_io_terminal_restore_stream_mode(const php_io_terminal_saved_mode *saved) { - if (!terminal_native_stream_is_valid(saved->stream)) { + if (!php_io_terminal_native_stream_is_valid(saved->stream)) { return false; } @@ -240,7 +236,7 @@ static bool terminal_restore_stream_mode(const terminal_saved_mode *saved) #endif } -static void terminal_untrack_mode_token(terminal_mode_token_object *mode) +static void php_io_terminal_untrack_mode_token(php_io_terminal_mode_token_object *mode) { if (!mode->tracked) { return; @@ -249,7 +245,7 @@ static void terminal_untrack_mode_token(terminal_mode_token_object *mode) if (mode->active_prev != NULL) { mode->active_prev->active_next = mode->active_next; } else { - terminal_active_mode_tokens = mode->active_next; + php_io_terminal_active_mode_tokens = mode->active_next; } if (mode->active_next != NULL) { @@ -261,25 +257,25 @@ static void terminal_untrack_mode_token(terminal_mode_token_object *mode) mode->tracked = false; } -static void terminal_track_mode_token(terminal_mode_token_object *mode) +static void php_io_terminal_track_mode_token(php_io_terminal_mode_token_object *mode) { mode->active_prev = NULL; - mode->active_next = terminal_active_mode_tokens; - if (terminal_active_mode_tokens != NULL) { - terminal_active_mode_tokens->active_prev = mode; + mode->active_next = php_io_terminal_active_mode_tokens; + if (php_io_terminal_active_mode_tokens != NULL) { + php_io_terminal_active_mode_tokens->active_prev = mode; } - terminal_active_mode_tokens = mode; + php_io_terminal_active_mode_tokens = mode; mode->tracked = true; } -static bool terminal_release_mode_token(terminal_mode_token_object *mode) +static bool php_io_terminal_release_mode_token(php_io_terminal_mode_token_object *mode) { - terminal_mode_token_object *candidate = terminal_active_mode_tokens; - terminal_mode_token_object *newer = NULL; + php_io_terminal_mode_token_object *candidate = php_io_terminal_active_mode_tokens; + php_io_terminal_mode_token_object *newer = NULL; bool restored; while (candidate != NULL && candidate != mode) { - if (candidate->valid && terminal_mode_streams_match(candidate->saved.stream, mode->saved.stream)) { + if (candidate->valid && php_io_terminal_mode_streams_match(candidate->saved.stream, mode->saved.stream)) { newer = candidate; } candidate = candidate->active_next; @@ -291,19 +287,19 @@ static bool terminal_release_mode_token(terminal_mode_token_object *mode) if (newer != NULL) { newer->saved.mode = mode->saved.mode; - terminal_untrack_mode_token(mode); + php_io_terminal_untrack_mode_token(mode); memset(&mode->saved, 0, sizeof(mode->saved)); mode->valid = false; return true; } - if (!terminal_mode_token_stream_is_valid(mode)) { + if (!php_io_terminal_mode_token_stream_is_valid(mode)) { return false; } - restored = terminal_restore_stream_mode(&mode->saved); + restored = php_io_terminal_restore_stream_mode(&mode->saved); if (restored) { - terminal_untrack_mode_token(mode); + php_io_terminal_untrack_mode_token(mode); memset(&mode->saved, 0, sizeof(mode->saved)); mode->valid = false; } @@ -311,14 +307,14 @@ static bool terminal_release_mode_token(terminal_mode_token_object *mode) return restored; } -static void terminal_mode_token_free_obj(zend_object *object) +static void php_io_terminal_mode_token_free_obj(zend_object *object) { - terminal_mode_token_object *intern = terminal_mode_token_from_obj(object); + php_io_terminal_mode_token_object *intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(object); - if (intern->valid && memcmp(intern->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - terminal_release_mode_token(intern); + if (intern->valid && memcmp(intern->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { + php_io_terminal_release_mode_token(intern); } - terminal_untrack_mode_token(intern); + php_io_terminal_untrack_mode_token(intern); if (!Z_ISUNDEF(intern->stream_resource)) { zval_ptr_dtor(&intern->stream_resource); @@ -331,9 +327,9 @@ static void terminal_mode_token_free_obj(zend_object *object) zend_object_std_dtor(&intern->std); } -static zend_object *terminal_mode_token_create_object(zend_class_entry *ce) +static zend_object *php_io_terminal_mode_token_create_object(zend_class_entry *ce) { - terminal_mode_token_object *intern = zend_object_alloc(sizeof(terminal_mode_token_object), ce); + php_io_terminal_mode_token_object *intern = zend_object_alloc(sizeof(*intern), ce); memset(&intern->saved, 0, sizeof(intern->saved)); ZVAL_UNDEF(&intern->stream_resource); @@ -344,33 +340,33 @@ static zend_object *terminal_mode_token_create_object(zend_class_entry *ce) zend_object_std_init(&intern->std, ce); object_properties_init(&intern->std, ce); - intern->std.handlers = &terminal_mode_token_handlers; + intern->std.handlers = &php_io_terminal_mode_token_handlers; return &intern->std; } -static void terminal_create_mode_token(zval *return_value, const terminal_saved_mode *saved, zval *stream_resource) +static void php_io_terminal_create_mode_token(zval *return_value, const php_io_terminal_saved_mode *saved, zval *stream_resource) { - terminal_mode_token_object *intern; + php_io_terminal_mode_token_object *intern; - object_init_ex(return_value, terminal_mode_token_ce); - intern = Z_TERMINAL_MODE_TOKEN_P(return_value); + object_init_ex(return_value, php_io_terminal_mode_token_ce); + intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value); intern->saved = *saved; if (stream_resource != NULL && !Z_ISUNDEF_P(stream_resource)) { ZVAL_COPY(&intern->stream_resource, stream_resource); } intern->valid = true; - terminal_track_mode_token(intern); + php_io_terminal_track_mode_token(intern); } -static void terminal_free_obj(zend_object *object) +static void php_io_terminal_free_obj(zend_object *object) { - terminal_object *intern = terminal_from_obj(object); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(object); if (intern->active_mode_token != NULL) { - terminal_mode_token_object *mode = terminal_mode_token_from_obj(intern->active_mode_token); - if (mode->valid && memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - terminal_release_mode_token(mode); + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); + if (mode->valid && memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { + php_io_terminal_release_mode_token(mode); } OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; @@ -389,9 +385,9 @@ static void terminal_free_obj(zend_object *object) zend_object_std_dtor(&intern->std); } -static zend_object *terminal_create_object(zend_class_entry *ce) +static zend_object *php_io_terminal_create_object(zend_class_entry *ce) { - terminal_object *intern = zend_object_alloc(sizeof(terminal_object), ce); + php_io_terminal_object *intern = zend_object_alloc(sizeof(*intern), ce); ZVAL_UNDEF(&intern->input_stream_val); ZVAL_UNDEF(&intern->output_stream_val); @@ -404,12 +400,12 @@ static zend_object *terminal_create_object(zend_class_entry *ce) zend_object_std_init(&intern->std, ce); object_properties_init(&intern->std, ce); - intern->std.handlers = &terminal_object_handlers; + intern->std.handlers = &php_io_terminal_object_handlers; return &intern->std; } -static terminal_native_stream terminal_native_stream_from_php_stream(php_stream *stream) +static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream) { #ifdef PHP_WIN32 php_socket_t descriptor = (php_socket_t) -1; @@ -440,10 +436,10 @@ static terminal_native_stream terminal_native_stream_from_php_stream(php_stream #endif } -static bool terminal_stream_target_init( +static bool php_io_terminal_stream_target_init( zval *stream_arg, bool is_input, - terminal_stream_target *target + php_io_terminal_stream_target *target ) { memset(target, 0, sizeof(*target)); @@ -471,7 +467,7 @@ static bool terminal_stream_target_init( target->is_default = false; target->stream_resource = stream_arg; - target->native_stream = terminal_native_stream_from_php_stream(target->php_stream); + target->native_stream = php_io_terminal_native_stream_from_php_stream(target->php_stream); return true; } @@ -479,25 +475,25 @@ static bool terminal_stream_target_init( } #ifdef PHP_WIN32 -static DWORD terminal_make_raw_mode(DWORD mode) +static DWORD php_io_terminal_make_raw_mode(DWORD mode) { return (mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)); } -static bool terminal_enable_stream_raw_mode(terminal_native_stream handle, terminal_saved_mode *saved) +static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream handle, php_io_terminal_saved_mode *saved) { DWORD mode; DWORD raw_mode; - if (!terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) { + if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) { return false; } - memcpy(saved->magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN); + memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); saved->stream = handle; saved->mode = mode; - raw_mode = terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; + raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; if (!SetConsoleMode(handle, raw_mode)) { return false; } @@ -505,11 +501,11 @@ static bool terminal_enable_stream_raw_mode(terminal_native_stream handle, termi return true; } -static bool terminal_stream_size(terminal_native_stream handle, zend_long *columns, zend_long *rows) +static bool php_io_terminal_stream_size(php_io_terminal_native_stream handle, zend_long *columns, zend_long *rows) { CONSOLE_SCREEN_BUFFER_INFO info; - if (!terminal_native_stream_is_valid(handle) || !GetConsoleScreenBufferInfo(handle, &info)) { + if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleScreenBufferInfo(handle, &info)) { return false; } @@ -519,7 +515,7 @@ static bool terminal_stream_size(terminal_native_stream handle, zend_long *colum return true; } -static zend_string *terminal_key_from_virtual_key(WORD vk) +static zend_string *php_io_terminal_key_from_virtual_key(WORD vk) { switch (vk) { case VK_UP: @@ -577,7 +573,7 @@ static zend_string *terminal_key_from_virtual_key(WORD vk) } } -static zend_string *terminal_key_from_wchar(WCHAR ch, WCHAR *high_surrogate) +static zend_string *php_io_terminal_key_from_wchar(WCHAR ch, WCHAR *high_surrogate) { char buffer[8]; WCHAR units[2]; @@ -613,24 +609,24 @@ static zend_string *terminal_key_from_wchar(WCHAR ch, WCHAR *high_surrogate) return buffer_len > 0 ? zend_string_init(buffer, (size_t) buffer_len, false) : NULL; } -static zend_string *terminal_key_from_input_record(const KEY_EVENT_RECORD *key, WCHAR *high_surrogate) +static zend_string *php_io_terminal_key_from_input_record(const KEY_EVENT_RECORD *key, WCHAR *high_surrogate) { - zend_string *named_key = terminal_key_from_virtual_key(key->wVirtualKeyCode); + zend_string *named_key = php_io_terminal_key_from_virtual_key(key->wVirtualKeyCode); if (named_key != NULL) { *high_surrogate = 0; return named_key; } - return terminal_key_from_wchar(key->uChar.UnicodeChar, high_surrogate); + return php_io_terminal_key_from_wchar(key->uChar.UnicodeChar, high_surrogate); } -static bool terminal_read_console_record(HANDLE handle, DWORD wait_ms, INPUT_RECORD *record, WCHAR *high_surrogate) +static bool php_io_terminal_read_console_record(HANDLE handle, DWORD wait_ms, INPUT_RECORD *record, WCHAR *high_surrogate) { DWORD records_read; - if (terminal_pending_key.Event.KeyEvent.wRepeatCount > 0) { - *record = terminal_pending_key; - *high_surrogate = terminal_pending_high_surrogate; - terminal_pending_key.Event.KeyEvent.wRepeatCount = 0; + if (php_io_terminal_pending_key.Event.KeyEvent.wRepeatCount > 0) { + *record = php_io_terminal_pending_key; + *high_surrogate = php_io_terminal_pending_high_surrogate; + php_io_terminal_pending_key.Event.KeyEvent.wRepeatCount = 0; return true; } @@ -638,7 +634,7 @@ static bool terminal_read_console_record(HANDLE handle, DWORD wait_ms, INPUT_REC && ReadConsoleInputW(handle, record, 1, &records_read) && records_read == 1; } -static zend_string *terminal_read_stream_key(terminal_native_stream input, php_stream *stream, DWORD wait_ms, WCHAR *pending_high_surrogate) +static zend_string *php_io_terminal_read_stream_key(php_io_terminal_native_stream input, php_stream *stream, DWORD wait_ms, WCHAR *pending_high_surrogate) { HANDLE handle = input; DWORD mode = 0; @@ -657,7 +653,7 @@ static zend_string *terminal_read_stream_key(terminal_native_stream input, php_s return NULL; } - raw_mode = terminal_make_raw_mode(mode); + raw_mode = php_io_terminal_make_raw_mode(mode); if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { return NULL; } @@ -675,7 +671,7 @@ static zend_string *terminal_read_stream_key(terminal_native_stream input, php_s remaining_ms = (DWORD) (deadline_ms - now); } - if (!terminal_read_console_record(handle, remaining_ms, &record, &high_surrogate)) { + if (!php_io_terminal_read_console_record(handle, remaining_ms, &record, &high_surrogate)) { break; } @@ -690,12 +686,12 @@ static zend_string *terminal_read_stream_key(terminal_native_stream input, php_s continue; } - result = terminal_key_from_input_record(key, &high_surrogate); + result = php_io_terminal_key_from_input_record(key, &high_surrogate); if (result != NULL) { if (key->wRepeatCount > 1) { key->wRepeatCount--; - terminal_pending_key = record; - terminal_pending_high_surrogate = high_surrogate; + php_io_terminal_pending_key = record; + php_io_terminal_pending_high_surrogate = high_surrogate; } break; } @@ -711,7 +707,7 @@ static zend_string *terminal_read_stream_key(terminal_native_stream input, php_s return result; } -static zend_string *terminal_read_stream_secret(terminal_native_stream input, php_stream *stream) +static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_stream input, php_stream *stream) { HANDLE handle = input; DWORD mode; @@ -728,7 +724,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph return NULL; } - raw_mode = terminal_make_raw_mode(mode); + raw_mode = php_io_terminal_make_raw_mode(mode); if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { return NULL; } @@ -739,7 +735,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph KEY_EVENT_RECORD *key; WORD repeats; - if (!terminal_read_console_record(handle, INFINITE, &record, &high_surrogate)) { + if (!php_io_terminal_read_console_record(handle, INFINITE, &record, &high_surrogate)) { failed = true; break; } @@ -782,7 +778,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph } if (key->uChar.UnicodeChar != 0) { - zend_string *utf8 = terminal_key_from_wchar(key->uChar.UnicodeChar, &high_surrogate); + zend_string *utf8 = php_io_terminal_key_from_wchar(key->uChar.UnicodeChar, &high_surrogate); if (utf8 != NULL) { smart_str_append(&secret, utf8); zend_string_release(utf8); @@ -805,7 +801,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph #else /* POSIX implementation */ -static void terminal_make_raw_mode(struct termios *mode) +static void php_io_terminal_make_raw_mode(struct termios *mode) { mode->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); mode->c_oflag &= ~OPOST; @@ -816,21 +812,21 @@ static void terminal_make_raw_mode(struct termios *mode) mode->c_cc[VTIME] = 0; } -static bool terminal_enable_stream_raw_mode(terminal_native_stream fd, terminal_saved_mode *saved) +static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream fd, php_io_terminal_saved_mode *saved) { struct termios mode; struct termios raw_mode; - if (!terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { return false; } - memcpy(saved->magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN); + memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); saved->stream = fd; saved->mode = mode; raw_mode = mode; - terminal_make_raw_mode(&raw_mode); + php_io_terminal_make_raw_mode(&raw_mode); if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { return false; @@ -839,11 +835,11 @@ static bool terminal_enable_stream_raw_mode(terminal_native_stream fd, terminal_ return true; } -static bool terminal_stream_size(terminal_native_stream fd, zend_long *columns, zend_long *rows) +static bool php_io_terminal_stream_size(php_io_terminal_native_stream fd, zend_long *columns, zend_long *rows) { struct winsize ws; - if (!terminal_native_stream_is_valid(fd)) { + if (!php_io_terminal_native_stream_is_valid(fd)) { return false; } @@ -857,7 +853,7 @@ static bool terminal_stream_size(terminal_native_stream fd, zend_long *columns, return true; } -static size_t terminal_utf8_sequence_len(unsigned char byte) +static size_t php_io_terminal_utf8_sequence_len(unsigned char byte) { if ((byte & 0x80) == 0) { return 1; @@ -874,7 +870,7 @@ static size_t terminal_utf8_sequence_len(unsigned char byte) return 1; } -static void terminal_buffer_remove_last_utf8_char(smart_str *secret) +static void php_io_terminal_buffer_remove_last_utf8_char(smart_str *secret) { size_t len; @@ -894,7 +890,38 @@ static void terminal_buffer_remove_last_utf8_char(smart_str *secret) ZSTR_LEN(secret->s) = len; } -static int terminal_read_byte(int fd, php_stream *stream, unsigned char *byte, int timeout_ms, bool allow_resize, const sig_atomic_t *generation) +static void php_io_terminal_duration_to_timeval(const php_date_time_duration *duration, struct timeval *timeout) +{ + uint64_t seconds = duration->duration.seconds; + uint64_t microseconds = ((uint64_t) duration->duration.nanoseconds + 999) / 1000; + + if (seconds > (uint64_t) LONG_MAX) { + timeout->tv_sec = LONG_MAX; + timeout->tv_usec = 999999; + return; + } + + timeout->tv_sec = (long) seconds; + timeout->tv_usec = (long) microseconds; + + if (timeout->tv_usec >= 1000000) { + if (timeout->tv_sec < LONG_MAX) { + timeout->tv_sec++; + timeout->tv_usec = 0; + } else { + timeout->tv_usec = 999999; + } + } +} + +static int php_io_terminal_read_byte( + int fd, + php_stream *stream, + unsigned char *byte, + const struct timeval *timeout, + bool allow_resize, + const sig_atomic_t *generation +) { fd_set read_fds; struct timeval tv; @@ -908,17 +935,16 @@ static int terminal_read_byte(int fd, php_stream *stream, unsigned char *byte, i for (;;) { #if defined(SIGWINCH) - if (allow_resize && generation != NULL && *generation != terminal_resize_generation) { - return TERMINAL_READ_RESIZE; + if (allow_resize && generation != NULL && *generation != php_io_terminal_resize_generation) { + return PHP_IO_TERMINAL_READ_RESIZE; } #endif FD_ZERO(&read_fds); FD_SET(fd, &read_fds); - if (timeout_ms >= 0) { - tv.tv_sec = timeout_ms / 1000; - tv.tv_usec = (timeout_ms % 1000) * 1000; + if (timeout != NULL) { + tv = *timeout; tv_ptr = &tv; } else { tv_ptr = NULL; @@ -935,8 +961,8 @@ static int terminal_read_byte(int fd, php_stream *stream, unsigned char *byte, i if (errno == EINTR) { #if defined(SIGWINCH) - if (allow_resize && generation != NULL && *generation != terminal_resize_generation) { - return TERMINAL_READ_RESIZE; + if (allow_resize && generation != NULL && *generation != php_io_terminal_resize_generation) { + return PHP_IO_TERMINAL_READ_RESIZE; } #endif continue; @@ -956,14 +982,20 @@ static int terminal_read_byte(int fd, php_stream *stream, unsigned char *byte, i return 1; } -static zend_string *terminal_finish_utf8_sequence(int fd, php_stream *stream, terminal_utf8_pending *pending, int first_timeout_ms, int sequence_timeout_ms) +static zend_string *php_io_terminal_finish_utf8_sequence( + int fd, + php_stream *stream, + php_io_terminal_utf8_pending *pending, + const struct timeval *first_timeout, + const struct timeval *sequence_timeout +) { size_t initial_length = pending->length; while (pending->length < pending->expected) { unsigned char key; - int timeout_ms = pending->length == initial_length ? first_timeout_ms : sequence_timeout_ms; - int result = terminal_read_byte(fd, stream, &key, timeout_ms, false, NULL); + const struct timeval *timeout = pending->length == initial_length ? first_timeout : sequence_timeout; + int result = php_io_terminal_read_byte(fd, stream, &key, timeout, false, NULL); if (result != 1) { return NULL; @@ -982,9 +1014,15 @@ static zend_string *terminal_finish_utf8_sequence(int fd, php_stream *stream, te return result; } -static zend_string *terminal_key_from_utf8_sequence(int fd, php_stream *stream, unsigned char key, int sequence_timeout_ms, terminal_utf8_pending *pending) +static zend_string *php_io_terminal_key_from_utf8_sequence( + int fd, + php_stream *stream, + unsigned char key, + const struct timeval *sequence_timeout, + php_io_terminal_utf8_pending *pending +) { - size_t sequence_len = terminal_utf8_sequence_len(key); + size_t sequence_len = php_io_terminal_utf8_sequence_len(key); if (sequence_len == 1) { return zend_string_init((const char *) &key, 1, false); @@ -995,10 +1033,14 @@ static zend_string *terminal_key_from_utf8_sequence(int fd, php_stream *stream, pending->length = 1; pending->expected = sequence_len; - return terminal_finish_utf8_sequence(fd, stream, pending, sequence_timeout_ms, sequence_timeout_ms); + return php_io_terminal_finish_utf8_sequence(fd, stream, pending, sequence_timeout, sequence_timeout); } -static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream, int sequence_timeout_ms) +static zend_string *php_io_terminal_key_from_escape_sequence( + int fd, + php_stream *stream, + const struct timeval *sequence_timeout +) { unsigned char seq[16]; size_t seq_len = 0; @@ -1006,14 +1048,14 @@ static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream seq[seq_len++] = 0x1b; - read_result = terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout_ms, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); if (read_result != 1) { return zend_string_init("escape", sizeof("escape") - 1, false); } seq_len++; if (seq[1] == '[') { - read_result = terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout_ms, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); if (read_result != 1) { return zend_string_init((const char *) seq, seq_len, false); } @@ -1034,7 +1076,7 @@ static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream case '6': { unsigned char next; - read_result = terminal_read_byte(fd, stream, &next, sequence_timeout_ms, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, &next, sequence_timeout, false, NULL); if (read_result == 1) { seq[seq_len++] = next; if (next == '~') { @@ -1048,7 +1090,7 @@ static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream } } else if (seq[2] == '1' || seq[2] == '2') { unsigned char terminator; - read_result = terminal_read_byte(fd, stream, &terminator, sequence_timeout_ms, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, &terminator, sequence_timeout, false, NULL); if (read_result == 1 && terminator == '~') { seq[seq_len++] = terminator; if (seq[2] == '1') { @@ -1077,7 +1119,7 @@ static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream } } } else if (seq[1] == 'O') { - read_result = terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout_ms, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); if (read_result == 1) { seq_len++; switch (seq[2]) { @@ -1094,7 +1136,13 @@ static zend_string *terminal_key_from_escape_sequence(int fd, php_stream *stream return zend_string_init((const char *) seq, seq_len, false); } -static zend_string *terminal_key_from_byte(int fd, php_stream *stream, unsigned char key, int sequence_timeout_ms, terminal_utf8_pending *pending) +static zend_string *php_io_terminal_key_from_byte( + int fd, + php_stream *stream, + unsigned char key, + const struct timeval *sequence_timeout, + php_io_terminal_utf8_pending *pending +) { switch (key) { case '\r': @@ -1106,13 +1154,19 @@ static zend_string *terminal_key_from_byte(int fd, php_stream *stream, unsigned case '\b': return zend_string_init("backspace", sizeof("backspace") - 1, false); case 0x1b: - return terminal_key_from_escape_sequence(fd, stream, sequence_timeout_ms); + return php_io_terminal_key_from_escape_sequence(fd, stream, sequence_timeout); default: - return terminal_key_from_utf8_sequence(fd, stream, key, sequence_timeout_ms, pending); + return php_io_terminal_key_from_utf8_sequence(fd, stream, key, sequence_timeout, pending); } } -static zend_string *terminal_read_stream_key(terminal_native_stream input, php_stream *stream, int timeout_ms, int sequence_timeout_ms, terminal_utf8_pending *pending) +static zend_string *php_io_terminal_read_stream_key( + php_io_terminal_native_stream input, + php_stream *stream, + const struct timeval *timeout, + const struct timeval *sequence_timeout, + php_io_terminal_utf8_pending *pending +) { int fd = input; struct termios mode; @@ -1129,40 +1183,40 @@ static zend_string *terminal_read_stream_key(terminal_native_stream input, php_s } #if defined(SIGWINCH) - resize_handler_installed = terminal_install_resize_handler(&resize_generation); + resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); #else resize_handler_installed = false; #endif raw_mode = mode; - terminal_make_raw_mode(&raw_mode); + php_io_terminal_make_raw_mode(&raw_mode); mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { #if defined(SIGWINCH) if (resize_handler_installed) { - terminal_restore_resize_handler(); + php_io_terminal_restore_resize_handler(); } #endif return NULL; } if (pending->length > 0) { - result = terminal_finish_utf8_sequence(fd, stream, pending, timeout_ms, sequence_timeout_ms); + result = php_io_terminal_finish_utf8_sequence(fd, stream, pending, timeout, sequence_timeout); } else { - read_result = terminal_read_byte(fd, stream, &key, timeout_ms, true, + read_result = php_io_terminal_read_byte(fd, stream, &key, timeout, true, resize_handler_installed ? &resize_generation : NULL); - if (read_result == TERMINAL_READ_RESIZE) { + if (read_result == PHP_IO_TERMINAL_READ_RESIZE) { result = zend_string_init("resize", sizeof("resize") - 1, false); } else if (read_result == 1) { - result = terminal_key_from_byte(fd, stream, key, sequence_timeout_ms, pending); + result = php_io_terminal_key_from_byte(fd, stream, key, sequence_timeout, pending); } } if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { #if defined(SIGWINCH) if (resize_handler_installed) { - terminal_restore_resize_handler(); + php_io_terminal_restore_resize_handler(); } #endif if (result != NULL) { @@ -1173,15 +1227,16 @@ static zend_string *terminal_read_stream_key(terminal_native_stream input, php_s #if defined(SIGWINCH) if (resize_handler_installed) { - terminal_restore_resize_handler(); + php_io_terminal_restore_resize_handler(); } #endif return result; } -static zend_string *terminal_read_stream_secret(terminal_native_stream input, php_stream *stream) +static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_stream input, php_stream *stream) { + const struct timeval sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000}; int fd = input; struct termios mode; struct termios raw_mode; @@ -1194,7 +1249,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph } raw_mode = mode; - terminal_make_raw_mode(&raw_mode); + php_io_terminal_make_raw_mode(&raw_mode); mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { @@ -1203,7 +1258,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph for (;;) { unsigned char key; - int result = terminal_read_byte(fd, stream, &key, -1, false, NULL); + int result = php_io_terminal_read_byte(fd, stream, &key, NULL, false, NULL); if (result != 1) { break; } @@ -1215,14 +1270,14 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph goto restore; case 0x7f: case '\b': - terminal_buffer_remove_last_utf8_char(&secret); + php_io_terminal_buffer_remove_last_utf8_char(&secret); break; case 0x03: case 0x04: goto restore; case 0x1b: { - zend_string *escape_key = terminal_key_from_escape_sequence(fd, stream, TERMINAL_SEQUENCE_TIMEOUT_MS); + zend_string *escape_key = php_io_terminal_key_from_escape_sequence(fd, stream, &sequence_timeout); bool is_escape = zend_string_equals_literal(escape_key, "escape"); zend_string_release(escape_key); if (is_escape) { @@ -1232,7 +1287,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph } default: { - size_t seq_len = terminal_utf8_sequence_len(key); + size_t seq_len = php_io_terminal_utf8_sequence_len(key); if (seq_len == 1) { smart_str_appendc(&secret, (char) key); } else { @@ -1240,7 +1295,7 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph size_t i; seq[0] = key; for (i = 1; i < seq_len; i++) { - if (terminal_read_byte(fd, stream, &seq[i], TERMINAL_SEQUENCE_TIMEOUT_MS, false, NULL) != 1) { + if (php_io_terminal_read_byte(fd, stream, &seq[i], &sequence_timeout, false, NULL) != 1) { break; } } @@ -1265,94 +1320,94 @@ static zend_string *terminal_read_stream_secret(terminal_native_stream input, ph } #endif /* !PHP_WIN32 */ -static zend_object *terminal_key_enum_from_string(zend_string *key) +static zend_object *php_io_terminal_key_enum_from_string(zend_string *key) { if (zend_string_equals_literal(key, "up")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Up"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Up"); } if (zend_string_equals_literal(key, "down")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Down"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Down"); } if (zend_string_equals_literal(key, "right")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Right"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Right"); } if (zend_string_equals_literal(key, "left")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Left"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Left"); } if (zend_string_equals_literal(key, "enter")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Enter"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Enter"); } if (zend_string_equals_literal(key, "backspace")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Backspace"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Backspace"); } if (zend_string_equals_literal(key, "escape")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Escape"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Escape"); } if (zend_string_equals_literal(key, "tab")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Tab"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Tab"); } if (zend_string_equals_literal(key, "home")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Home"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Home"); } if (zend_string_equals_literal(key, "end")) { - return zend_enum_get_case_cstr(terminal_key_ce, "End"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "End"); } if (zend_string_equals_literal(key, "delete")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Delete"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Delete"); } if (zend_string_equals_literal(key, "pageup")) { - return zend_enum_get_case_cstr(terminal_key_ce, "PageUp"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "PageUp"); } if (zend_string_equals_literal(key, "pagedown")) { - return zend_enum_get_case_cstr(terminal_key_ce, "PageDown"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "PageDown"); } if (zend_string_equals_literal(key, "resize")) { - return zend_enum_get_case_cstr(terminal_key_ce, "Resize"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize"); } if (zend_string_equals_literal(key, "f1")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F1"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F1"); } if (zend_string_equals_literal(key, "f2")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F2"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F2"); } if (zend_string_equals_literal(key, "f3")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F3"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F3"); } if (zend_string_equals_literal(key, "f4")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F4"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F4"); } if (zend_string_equals_literal(key, "f5")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F5"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F5"); } if (zend_string_equals_literal(key, "f6")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F6"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F6"); } if (zend_string_equals_literal(key, "f7")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F7"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F7"); } if (zend_string_equals_literal(key, "f8")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F8"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F8"); } if (zend_string_equals_literal(key, "f9")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F9"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F9"); } if (zend_string_equals_literal(key, "f10")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F10"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F10"); } if (zend_string_equals_literal(key, "f11")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F11"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F11"); } if (zend_string_equals_literal(key, "f12")) { - return zend_enum_get_case_cstr(terminal_key_ce, "F12"); + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F12"); } return NULL; } -static void terminal_create_terminal_size(zval *return_value, zend_long cols, zend_long rows) +static void php_io_terminal_create_terminal_size(zval *return_value, zend_long cols, zend_long rows) { - object_init_ex(return_value, terminal_terminal_size_ce); - zend_update_property_long(terminal_terminal_size_ce, Z_OBJ_P(return_value), "cols", sizeof("cols") - 1, cols); - zend_update_property_long(terminal_terminal_size_ce, Z_OBJ_P(return_value), "rows", sizeof("rows") - 1, rows); + object_init_ex(return_value, php_io_terminal_terminal_size_ce); + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(return_value), "cols", sizeof("cols") - 1, cols); + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(return_value), "rows", sizeof("rows") - 1, rows); } /* Io\Terminal\TerminalSize methods */ @@ -1377,15 +1432,15 @@ PHP_METHOD(Io_Terminal_Terminal, create) { ZEND_PARSE_PARAMETERS_NONE(); - object_init_ex(return_value, terminal_terminal_ce); + object_init_ex(return_value, php_io_terminal_terminal_ce); } PHP_METHOD(Io_Terminal_Terminal, fromStreams) { zval *input_arg; zval *output_arg = NULL; - terminal_object *intern; - terminal_stream_target target; + php_io_terminal_object *intern; + php_io_terminal_stream_target target; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_RESOURCE(input_arg) @@ -1393,21 +1448,21 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) Z_PARAM_RESOURCE_OR_NULL(output_arg) ZEND_PARSE_PARAMETERS_END(); - if (!terminal_stream_target_init(input_arg, true, &target) || target.php_stream == NULL) { + if (!php_io_terminal_stream_target_init(input_arg, true, &target) || target.php_stream == NULL) { zend_argument_value_error(1, "must be a valid stream resource"); RETURN_THROWS(); } if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { - terminal_stream_target out_target; - if (!terminal_stream_target_init(output_arg, false, &out_target) || out_target.php_stream == NULL) { + php_io_terminal_stream_target out_target; + if (!php_io_terminal_stream_target_init(output_arg, false, &out_target) || out_target.php_stream == NULL) { zend_argument_value_error(2, "must be a valid stream resource or null"); RETURN_THROWS(); } } - object_init_ex(return_value, terminal_terminal_ce); - intern = Z_TERMINAL_P(return_value); + object_init_ex(return_value, php_io_terminal_terminal_ce); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); ZVAL_COPY(&intern->input_stream_val, input_arg); if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { @@ -1419,38 +1474,38 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) PHP_METHOD(Io_Terminal_Terminal, getSize) { - terminal_object *intern; - terminal_stream_target stream; + php_io_terminal_object *intern; + php_io_terminal_stream_target stream; zend_long columns = 0; zend_long rows = 0; ZEND_PARSE_PARAMETERS_NONE(); - intern = Z_TERMINAL_P(ZEND_THIS); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); - if (!terminal_stream_target_init(&intern->output_stream_val, false, &stream)) { + if (!php_io_terminal_stream_target_init(&intern->output_stream_val, false, &stream)) { RETURN_THROWS(); } - if (!terminal_stream_size(stream.native_stream, &columns, &rows)) { + if (!php_io_terminal_stream_size(stream.native_stream, &columns, &rows)) { RETURN_FALSE; } - terminal_create_terminal_size(return_value, columns, rows); + php_io_terminal_create_terminal_size(return_value, columns, rows); } PHP_METHOD(Io_Terminal_Terminal, enableRawMode) { - terminal_object *intern; - terminal_stream_target stream; - terminal_saved_mode saved; + php_io_terminal_object *intern; + php_io_terminal_stream_target stream; + php_io_terminal_saved_mode saved; ZEND_PARSE_PARAMETERS_NONE(); - intern = Z_TERMINAL_P(ZEND_THIS); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); if (intern->active_mode_token != NULL) { - terminal_mode_token_object *mode = terminal_mode_token_from_obj(intern->active_mode_token); + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); if (mode->valid) { RETURN_OBJ_COPY(intern->active_mode_token); } @@ -1458,17 +1513,17 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) intern->active_mode_token = NULL; } - if (!terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); } - if (!terminal_enable_stream_raw_mode(stream.native_stream, &saved)) { + if (!php_io_terminal_enable_stream_raw_mode(stream.native_stream, &saved)) { RETURN_FALSE; } - terminal_create_mode_token(return_value, &saved, stream.stream_resource); + php_io_terminal_create_mode_token(return_value, &saved, stream.stream_resource); - if (Z_TYPE_P(return_value) == IS_OBJECT && instanceof_function(Z_OBJCE_P(return_value), terminal_mode_token_ce)) { + if (Z_TYPE_P(return_value) == IS_OBJECT && instanceof_function(Z_OBJCE_P(return_value), php_io_terminal_mode_token_ce)) { intern->active_mode_token = Z_OBJ_P(return_value); GC_ADDREF(intern->active_mode_token); } @@ -1477,25 +1532,25 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) PHP_METHOD(Io_Terminal_Terminal, restoreMode) { zval *mode_token = NULL; - terminal_object *intern; + php_io_terminal_object *intern; ZEND_PARSE_PARAMETERS_START(0, 1) Z_PARAM_OPTIONAL - Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, terminal_mode_token_ce) + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_ce) ZEND_PARSE_PARAMETERS_END(); - intern = Z_TERMINAL_P(ZEND_THIS); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { - terminal_mode_token_object *mode = Z_TERMINAL_MODE_TOKEN_P(mode_token); + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token); bool restored; - if (!mode->valid || memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { + if (!mode->valid || memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { zend_argument_value_error(1, "must be an active terminal mode token returned by Io\\Terminal\\Terminal::enableRawMode()"); RETURN_THROWS(); } - restored = terminal_release_mode_token(mode); + restored = php_io_terminal_release_mode_token(mode); if (restored && intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; @@ -1505,16 +1560,16 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) } if (intern->active_mode_token != NULL) { - terminal_mode_token_object *mode = terminal_mode_token_from_obj(intern->active_mode_token); + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); bool restored; - if (!mode->valid || memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { + if (!mode->valid || memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; RETURN_FALSE; } - restored = terminal_release_mode_token(mode); + restored = php_io_terminal_release_mode_token(mode); if (restored) { OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; @@ -1530,8 +1585,8 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) { php_date_time_duration *timeout_duration = NULL; php_date_time_duration *seq_timeout_duration = NULL; - terminal_object *intern; - terminal_stream_target stream; + php_io_terminal_object *intern; + php_io_terminal_stream_target stream; zend_string *key; zend_object *key_case; @@ -1551,9 +1606,9 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) RETURN_THROWS(); } - intern = Z_TERMINAL_P(ZEND_THIS); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); - if (!terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); } @@ -1572,44 +1627,35 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) } } - key = terminal_read_stream_key(stream.native_stream, stream.php_stream, wait_ms, &intern->pending_high_surrogate); + key = php_io_terminal_read_stream_key(stream.native_stream, stream.php_stream, wait_ms, &intern->pending_high_surrogate); #else - int timeout_ms = -1; + struct timeval timeout; + struct timeval sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000}; + const struct timeval *timeout_ptr = NULL; + if (timeout_duration != NULL) { - uint64_t sec = timeout_duration->duration.seconds; - uint32_t nsec = timeout_duration->duration.nanoseconds; - if (sec == 0 && nsec == 0) { - timeout_ms = 0; - } else if (sec >= (uint64_t) INT_MAX / 1000) { - timeout_ms = INT_MAX; - } else { - uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; - timeout_ms = ms >= (uint64_t) INT_MAX ? INT_MAX : (int) ms; - } + php_io_terminal_duration_to_timeval(timeout_duration, &timeout); + timeout_ptr = &timeout; } - int sequence_timeout_ms = TERMINAL_SEQUENCE_TIMEOUT_MS; if (seq_timeout_duration != NULL) { - uint64_t sec = seq_timeout_duration->duration.seconds; - uint32_t nsec = seq_timeout_duration->duration.nanoseconds; - if (sec == 0 && nsec == 0) { - sequence_timeout_ms = 0; - } else if (sec >= (uint64_t) INT_MAX / 1000) { - sequence_timeout_ms = INT_MAX; - } else { - uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; - sequence_timeout_ms = ms >= (uint64_t) INT_MAX ? INT_MAX : (int) ms; - } + php_io_terminal_duration_to_timeval(seq_timeout_duration, &sequence_timeout); } - key = terminal_read_stream_key(stream.native_stream, stream.php_stream, timeout_ms, sequence_timeout_ms, &intern->pending_utf8); + key = php_io_terminal_read_stream_key( + stream.native_stream, + stream.php_stream, + timeout_ptr, + &sequence_timeout, + &intern->pending_utf8 + ); #endif if (key == NULL) { RETURN_FALSE; } - key_case = terminal_key_enum_from_string(key); + key_case = php_io_terminal_key_enum_from_string(key); if (key_case != NULL) { zend_string_release(key); RETURN_OBJ_COPY(key_case); @@ -1620,13 +1666,13 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) PHP_METHOD(Io_Terminal_Terminal, readSecret) { - terminal_object *intern; - terminal_stream_target stream; + php_io_terminal_object *intern; + php_io_terminal_stream_target stream; zend_string *secret; ZEND_PARSE_PARAMETERS_NONE(); - intern = Z_TERMINAL_P(ZEND_THIS); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); #ifdef PHP_WIN32 intern->pending_high_surrogate = 0; @@ -1634,11 +1680,11 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); #endif - if (!terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); } - secret = terminal_read_stream_secret(stream.native_stream, stream.php_stream); + secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream); if (secret != NULL) { RETURN_STR(secret); } @@ -1649,30 +1695,30 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) PHP_MINIT_FUNCTION(terminal) { - terminal_key_ce = register_class_Io_Terminal_Key(); + php_io_terminal_key_ce = register_class_Io_Terminal_Key(); - terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); + php_io_terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); - terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); - terminal_mode_token_ce->create_object = terminal_mode_token_create_object; - terminal_mode_token_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES | ZEND_ACC_NOT_SERIALIZABLE; + php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); + php_io_terminal_mode_token_ce->create_object = php_io_terminal_mode_token_create_object; + php_io_terminal_mode_token_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES; - memcpy(&terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(terminal_mode_token_handlers)); - terminal_mode_token_handlers.offset = offsetof(terminal_mode_token_object, std); - terminal_mode_token_handlers.free_obj = terminal_mode_token_free_obj; - terminal_mode_token_handlers.clone_obj = NULL; + memcpy(&php_io_terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_mode_token_handlers)); + php_io_terminal_mode_token_handlers.offset = offsetof(php_io_terminal_mode_token_object, std); + php_io_terminal_mode_token_handlers.free_obj = php_io_terminal_mode_token_free_obj; + php_io_terminal_mode_token_handlers.clone_obj = NULL; - terminal_terminal_ce = register_class_Io_Terminal_Terminal(); - terminal_terminal_ce->create_object = terminal_create_object; - terminal_terminal_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES | ZEND_ACC_NOT_SERIALIZABLE; + php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(); + php_io_terminal_terminal_ce->create_object = php_io_terminal_create_object; + php_io_terminal_terminal_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES; - memcpy(&terminal_object_handlers, zend_get_std_object_handlers(), sizeof(terminal_object_handlers)); - terminal_object_handlers.offset = offsetof(terminal_object, std); - terminal_object_handlers.free_obj = terminal_free_obj; - terminal_object_handlers.clone_obj = NULL; + memcpy(&php_io_terminal_object_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_object_handlers)); + php_io_terminal_object_handlers.offset = offsetof(php_io_terminal_object, std); + php_io_terminal_object_handlers.free_obj = php_io_terminal_free_obj; + php_io_terminal_object_handlers.clone_obj = NULL; #if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) - terminal_resize_mutex = tsrm_mutex_alloc(); + php_io_terminal_resize_mutex = tsrm_mutex_alloc(); #endif return SUCCESS; @@ -1681,9 +1727,9 @@ PHP_MINIT_FUNCTION(terminal) PHP_MSHUTDOWN_FUNCTION(terminal) { #if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) - if (terminal_resize_mutex != NULL) { - tsrm_mutex_free(terminal_resize_mutex); - terminal_resize_mutex = NULL; + if (php_io_terminal_resize_mutex != NULL) { + tsrm_mutex_free(php_io_terminal_resize_mutex); + php_io_terminal_resize_mutex = NULL; } #endif @@ -1692,22 +1738,22 @@ PHP_MSHUTDOWN_FUNCTION(terminal) PHP_RINIT_FUNCTION(terminal) { - terminal_active_mode_tokens = NULL; + php_io_terminal_active_mode_tokens = NULL; #ifdef PHP_WIN32 - memset(&terminal_pending_key, 0, sizeof(terminal_pending_key)); - terminal_pending_high_surrogate = 0; + memset(&php_io_terminal_pending_key, 0, sizeof(php_io_terminal_pending_key)); + php_io_terminal_pending_high_surrogate = 0; #endif return SUCCESS; } PHP_RSHUTDOWN_FUNCTION(terminal) { - while (terminal_active_mode_tokens != NULL) { - terminal_mode_token_object *mode = terminal_active_mode_tokens; - if (mode->valid && memcmp(mode->saved.magic, TERMINAL_MODE_TOKEN_MAGIC, TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - terminal_release_mode_token(mode); + while (php_io_terminal_active_mode_tokens != NULL) { + php_io_terminal_mode_token_object *mode = php_io_terminal_active_mode_tokens; + if (mode->valid && memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { + php_io_terminal_release_mode_token(mode); } else { - terminal_untrack_mode_token(mode); + php_io_terminal_untrack_mode_token(mode); } } return SUCCESS; From 37be035e6276b6ecf7302dae9fde99776453bae1 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 19:12:37 +0545 Subject: [PATCH 07/38] Harden terminal input and mode restoration --- ext/standard/io_terminal.c | 297 ++++++++++++++++++++++++------------- 1 file changed, 196 insertions(+), 101 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index b705985fa1aa..c9c9844964af 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -51,8 +51,6 @@ #ifdef PHP_WIN32 typedef HANDLE php_io_terminal_native_stream; -ZEND_TLS INPUT_RECORD php_io_terminal_pending_key; -ZEND_TLS WCHAR php_io_terminal_pending_high_surrogate; #else typedef int php_io_terminal_native_stream; @@ -88,6 +86,8 @@ typedef struct php_io_terminal_object { zval output_stream_val; zend_object *active_mode_token; #ifdef PHP_WIN32 + INPUT_RECORD pending_key; + WCHAR pending_key_high_surrogate; WCHAR pending_high_surrogate; #else php_io_terminal_utf8_pending pending_utf8; @@ -198,6 +198,8 @@ static void php_io_terminal_restore_resize_handler(void) #define PHP_IO_TERMINAL_OBJ_FROM_ZV(_zv) \ PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv)) +static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream); + static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream stream) { #ifdef PHP_WIN32 @@ -215,7 +217,26 @@ static bool php_io_terminal_mode_streams_match(php_io_terminal_native_stream fir static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mode_token_object *mode) { if (!Z_ISUNDEF(mode->stream_resource)) { - if (Z_TYPE(mode->stream_resource) != IS_RESOURCE || Z_RES(mode->stream_resource) == NULL) { + php_stream *stream; + php_io_terminal_native_stream native_stream; + + if (Z_TYPE(mode->stream_resource) != IS_RESOURCE) { + return false; + } + + stream = (php_stream *) zend_fetch_resource2( + Z_RES(mode->stream_resource), + NULL, + php_file_le_stream(), + php_file_le_pstream() + ); + if (stream == NULL) { + return false; + } + + native_stream = php_io_terminal_native_stream_from_php_stream(stream); + if (!php_io_terminal_native_stream_is_valid(native_stream) + || !php_io_terminal_mode_streams_match(native_stream, mode->saved.stream)) { return false; } } @@ -393,6 +414,8 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) ZVAL_UNDEF(&intern->output_stream_val); intern->active_mode_token = NULL; #ifdef PHP_WIN32 + memset(&intern->pending_key, 0, sizeof(intern->pending_key)); + intern->pending_key_high_surrogate = 0; intern->pending_high_surrogate = 0; #else memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); @@ -619,14 +642,21 @@ static zend_string *php_io_terminal_key_from_input_record(const KEY_EVENT_RECORD return php_io_terminal_key_from_wchar(key->uChar.UnicodeChar, high_surrogate); } -static bool php_io_terminal_read_console_record(HANDLE handle, DWORD wait_ms, INPUT_RECORD *record, WCHAR *high_surrogate) +static bool php_io_terminal_read_console_record( + HANDLE handle, + DWORD wait_ms, + INPUT_RECORD *record, + WCHAR *high_surrogate, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate +) { DWORD records_read; - if (php_io_terminal_pending_key.Event.KeyEvent.wRepeatCount > 0) { - *record = php_io_terminal_pending_key; - *high_surrogate = php_io_terminal_pending_high_surrogate; - php_io_terminal_pending_key.Event.KeyEvent.wRepeatCount = 0; + if (pending_key->Event.KeyEvent.wRepeatCount > 0) { + *record = *pending_key; + *high_surrogate = *pending_key_high_surrogate; + pending_key->Event.KeyEvent.wRepeatCount = 0; return true; } @@ -634,7 +664,14 @@ static bool php_io_terminal_read_console_record(HANDLE handle, DWORD wait_ms, IN && ReadConsoleInputW(handle, record, 1, &records_read) && records_read == 1; } -static zend_string *php_io_terminal_read_stream_key(php_io_terminal_native_stream input, php_stream *stream, DWORD wait_ms, WCHAR *pending_high_surrogate) +static zend_string *php_io_terminal_read_stream_key( + php_io_terminal_native_stream input, + php_stream *stream, + DWORD wait_ms, + WCHAR *pending_high_surrogate, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate +) { HANDLE handle = input; DWORD mode = 0; @@ -653,7 +690,7 @@ static zend_string *php_io_terminal_read_stream_key(php_io_terminal_native_strea return NULL; } - raw_mode = php_io_terminal_make_raw_mode(mode); + raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { return NULL; } @@ -665,13 +702,16 @@ static zend_string *php_io_terminal_read_stream_key(php_io_terminal_native_strea if (wait_ms != INFINITE) { ULONGLONG now = GetTickCount64(); - if (now >= deadline_ms) { - break; - } - remaining_ms = (DWORD) (deadline_ms - now); + remaining_ms = now >= deadline_ms ? 0 : (DWORD) (deadline_ms - now); } - if (!php_io_terminal_read_console_record(handle, remaining_ms, &record, &high_surrogate)) { + if (!php_io_terminal_read_console_record( + handle, + remaining_ms, + &record, + &high_surrogate, + pending_key, + pending_key_high_surrogate)) { break; } @@ -690,8 +730,8 @@ static zend_string *php_io_terminal_read_stream_key(php_io_terminal_native_strea if (result != NULL) { if (key->wRepeatCount > 1) { key->wRepeatCount--; - php_io_terminal_pending_key = record; - php_io_terminal_pending_high_surrogate = high_surrogate; + *pending_key = record; + *pending_key_high_surrogate = high_surrogate; } break; } @@ -700,14 +740,22 @@ static zend_string *php_io_terminal_read_stream_key(php_io_terminal_native_strea *pending_high_surrogate = high_surrogate; - if (mode_changed) { - SetConsoleMode(handle, mode); + if (mode_changed && !SetConsoleMode(handle, mode)) { + if (result != NULL) { + zend_string_release(result); + } + return NULL; } return result; } -static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_stream input, php_stream *stream) +static zend_string *php_io_terminal_read_stream_secret( + php_io_terminal_native_stream input, + php_stream *stream, + INPUT_RECORD *pending_key, + WCHAR *pending_key_high_surrogate +) { HANDLE handle = input; DWORD mode; @@ -735,7 +783,13 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st KEY_EVENT_RECORD *key; WORD repeats; - if (!php_io_terminal_read_console_record(handle, INFINITE, &record, &high_surrogate)) { + if (!php_io_terminal_read_console_record( + handle, + INFINITE, + &record, + &high_surrogate, + pending_key, + pending_key_high_surrogate)) { failed = true; break; } @@ -755,7 +809,8 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st } if (key->wVirtualKeyCode == VK_ESCAPE - || (key->wVirtualKeyCode == 'C' && (key->dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0)) { + || ((key->wVirtualKeyCode == 'C' || key->wVirtualKeyCode == 'D') + && (key->dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0)) { failed = true; break; } @@ -787,8 +842,8 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st } } - if (mode_changed) { - SetConsoleMode(handle, mode); + if (mode_changed && !SetConsoleMode(handle, mode)) { + failed = true; } if (success && !failed) { @@ -914,6 +969,34 @@ static void php_io_terminal_duration_to_timeval(const php_date_time_duration *du } } +static bool php_io_terminal_timeval_subtract_elapsed(struct timeval *remaining, zend_hrtime_t elapsed_ns) +{ + uint64_t elapsed_seconds = elapsed_ns / ZEND_NANO_IN_SEC; + uint64_t elapsed_microseconds = (elapsed_ns % ZEND_NANO_IN_SEC + 999) / 1000; + + if (elapsed_microseconds >= 1000000) { + elapsed_seconds++; + elapsed_microseconds -= 1000000; + } + + if (elapsed_seconds > (uint64_t) remaining->tv_sec + || (elapsed_seconds == (uint64_t) remaining->tv_sec + && elapsed_microseconds >= (uint64_t) remaining->tv_usec)) { + remaining->tv_sec = 0; + remaining->tv_usec = 0; + return false; + } + + remaining->tv_sec -= (long) elapsed_seconds; + if ((uint64_t) remaining->tv_usec < elapsed_microseconds) { + remaining->tv_sec--; + remaining->tv_usec += 1000000; + } + remaining->tv_usec -= (long) elapsed_microseconds; + + return true; +} + static int php_io_terminal_read_byte( int fd, php_stream *stream, @@ -924,8 +1007,7 @@ static int php_io_terminal_read_byte( ) { fd_set read_fds; - struct timeval tv; - struct timeval *tv_ptr = NULL; + struct timeval remaining; int select_result; ssize_t bytes_read; @@ -933,7 +1015,20 @@ static int php_io_terminal_read_byte( return php_stream_read(stream, (char *) byte, 1) == 1 ? 1 : -1; } + if (fd < 0 || fd >= FD_SETSIZE) { + errno = EINVAL; + return -1; + } + + if (timeout != NULL) { + remaining = *timeout; + } + for (;;) { + struct timeval wait_time; + struct timeval *wait_time_ptr = NULL; + zend_hrtime_t start_ns = 0; + #if defined(SIGWINCH) if (allow_resize && generation != NULL && *generation != php_io_terminal_resize_generation) { return PHP_IO_TERMINAL_READ_RESIZE; @@ -944,13 +1039,12 @@ static int php_io_terminal_read_byte( FD_SET(fd, &read_fds); if (timeout != NULL) { - tv = *timeout; - tv_ptr = &tv; - } else { - tv_ptr = NULL; + wait_time = remaining; + wait_time_ptr = &wait_time; + start_ns = zend_hrtime(); } - select_result = select(fd + 1, &read_fds, NULL, NULL, tv_ptr); + select_result = select(fd + 1, &read_fds, NULL, NULL, wait_time_ptr); if (select_result > 0) { break; } @@ -965,6 +1059,13 @@ static int php_io_terminal_read_byte( return PHP_IO_TERMINAL_READ_RESIZE; } #endif + if (timeout != NULL) { + zend_hrtime_t elapsed_ns = zend_hrtime() - start_ns; + if (UNEXPECTED(elapsed_ns == 0) + || !php_io_terminal_timeval_subtract_elapsed(&remaining, elapsed_ns)) { + return 0; + } + } continue; } @@ -1042,7 +1143,7 @@ static zend_string *php_io_terminal_key_from_escape_sequence( const struct timeval *sequence_timeout ) { - unsigned char seq[16]; + unsigned char seq[32]; size_t seq_len = 0; int read_result; @@ -1055,70 +1156,48 @@ static zend_string *php_io_terminal_key_from_escape_sequence( seq_len++; if (seq[1] == '[') { - read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); - if (read_result != 1) { - return zend_string_init((const char *) seq, seq_len, false); - } - seq_len++; - - switch (seq[2]) { - case 'A': return zend_string_init("up", sizeof("up") - 1, false); - case 'B': return zend_string_init("down", sizeof("down") - 1, false); - case 'C': return zend_string_init("right", sizeof("right") - 1, false); - case 'D': return zend_string_init("left", sizeof("left") - 1, false); - case 'H': return zend_string_init("home", sizeof("home") - 1, false); - case 'F': return zend_string_init("end", sizeof("end") - 1, false); - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - { - unsigned char next; - read_result = php_io_terminal_read_byte(fd, stream, &next, sequence_timeout, false, NULL); - if (read_result == 1) { - seq[seq_len++] = next; - if (next == '~') { - switch (seq[2]) { - case '1': return zend_string_init("home", sizeof("home") - 1, false); - case '2': return zend_string_init("insert", sizeof("insert") - 1, false); - case '3': return zend_string_init("delete", sizeof("delete") - 1, false); - case '4': return zend_string_init("end", sizeof("end") - 1, false); - case '5': return zend_string_init("pageup", sizeof("pageup") - 1, false); - case '6': return zend_string_init("pagedown", sizeof("pagedown") - 1, false); - } - } else if (seq[2] == '1' || seq[2] == '2') { - unsigned char terminator; - read_result = php_io_terminal_read_byte(fd, stream, &terminator, sequence_timeout, false, NULL); - if (read_result == 1 && terminator == '~') { - seq[seq_len++] = terminator; - if (seq[2] == '1') { - switch (next) { - case '1': return zend_string_init("f1", sizeof("f1") - 1, false); - case '2': return zend_string_init("f2", sizeof("f2") - 1, false); - case '3': return zend_string_init("f3", sizeof("f3") - 1, false); - case '4': return zend_string_init("f4", sizeof("f4") - 1, false); - case '5': return zend_string_init("f5", sizeof("f5") - 1, false); - case '7': return zend_string_init("f6", sizeof("f6") - 1, false); - case '8': return zend_string_init("f7", sizeof("f7") - 1, false); - case '9': return zend_string_init("f8", sizeof("f8") - 1, false); - } - } else if (seq[2] == '2') { - switch (next) { - case '0': return zend_string_init("f9", sizeof("f9") - 1, false); - case '1': return zend_string_init("f10", sizeof("f10") - 1, false); - case '3': return zend_string_init("f11", sizeof("f11") - 1, false); - case '4': return zend_string_init("f12", sizeof("f12") - 1, false); - } - } - } - } - } + while (seq_len < sizeof(seq)) { + unsigned char next; + + read_result = php_io_terminal_read_byte(fd, stream, &next, sequence_timeout, false, NULL); + if (read_result != 1) { + break; + } + + seq[seq_len++] = next; + if (next >= 0x40 && next <= 0x7e) { break; } } - } else if (seq[1] == 'O') { + +#define PHP_IO_TERMINAL_CSI_IS(literal) \ + (seq_len == sizeof(literal) - 1 && memcmp(seq, literal, sizeof(literal) - 1) == 0) + + if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return zend_string_init("up", sizeof("up") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return zend_string_init("down", sizeof("down") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return zend_string_init("right", sizeof("right") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return zend_string_init("left", sizeof("left") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[H") || PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return zend_string_init("home", sizeof("home") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[F") || PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return zend_string_init("end", sizeof("end") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return zend_string_init("delete", sizeof("delete") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return zend_string_init("pageup", sizeof("pageup") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return zend_string_init("pagedown", sizeof("pagedown") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return zend_string_init("tab", sizeof("tab") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[P") || PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return zend_string_init("f1", sizeof("f1") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[Q") || PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return zend_string_init("f2", sizeof("f2") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[R") || PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return zend_string_init("f3", sizeof("f3") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[S") || PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return zend_string_init("f4", sizeof("f4") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return zend_string_init("f5", sizeof("f5") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return zend_string_init("f6", sizeof("f6") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return zend_string_init("f7", sizeof("f7") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return zend_string_init("f8", sizeof("f8") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return zend_string_init("f9", sizeof("f9") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return zend_string_init("f10", sizeof("f10") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return zend_string_init("f11", sizeof("f11") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return zend_string_init("f12", sizeof("f12") - 1, false); + +#undef PHP_IO_TERMINAL_CSI_IS + } else if (seq[1] == 'O' && seq_len < sizeof(seq)) { read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); if (read_result == 1) { seq_len++; @@ -1307,8 +1386,8 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st } restore: - if (mode_changed) { - tcsetattr(fd, TCSANOW, &mode); + if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { + success = false; } if (success) { @@ -1627,7 +1706,14 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) } } - key = php_io_terminal_read_stream_key(stream.native_stream, stream.php_stream, wait_ms, &intern->pending_high_surrogate); + key = php_io_terminal_read_stream_key( + stream.native_stream, + stream.php_stream, + wait_ms, + &intern->pending_high_surrogate, + &intern->pending_key, + &intern->pending_key_high_surrogate + ); #else struct timeval timeout; struct timeval sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000}; @@ -1684,7 +1770,16 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) RETURN_THROWS(); } +#ifdef PHP_WIN32 + secret = php_io_terminal_read_stream_secret( + stream.native_stream, + stream.php_stream, + &intern->pending_key, + &intern->pending_key_high_surrogate + ); +#else secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream); +#endif if (secret != NULL) { RETURN_STR(secret); } @@ -1739,10 +1834,6 @@ PHP_MSHUTDOWN_FUNCTION(terminal) PHP_RINIT_FUNCTION(terminal) { php_io_terminal_active_mode_tokens = NULL; -#ifdef PHP_WIN32 - memset(&php_io_terminal_pending_key, 0, sizeof(php_io_terminal_pending_key)); - php_io_terminal_pending_high_surrogate = 0; -#endif return SUCCESS; } @@ -1751,7 +1842,11 @@ PHP_RSHUTDOWN_FUNCTION(terminal) while (php_io_terminal_active_mode_tokens != NULL) { php_io_terminal_mode_token_object *mode = php_io_terminal_active_mode_tokens; if (mode->valid && memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - php_io_terminal_release_mode_token(mode); + if (!php_io_terminal_release_mode_token(mode)) { + php_io_terminal_untrack_mode_token(mode); + memset(&mode->saved, 0, sizeof(mode->saved)); + mode->valid = false; + } } else { php_io_terminal_untrack_mode_token(mode); } From 1b666b562193efcd28ce92c9b2675216a12d640e Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 19:14:09 +0545 Subject: [PATCH 08/38] Test terminal escape sequence handling --- .../terminal/terminal_read_key_sequences.phpt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 ext/standard/tests/terminal/terminal_read_key_sequences.phpt diff --git a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt new file mode 100644 index 000000000000..26b1bfe96bdd --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt @@ -0,0 +1,85 @@ +--TEST-- +Io\Terminal\Terminal: readKey preserves unsupported CSI sequences +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} + +if (!is_resource($proc)) { + die("skip PTY is not available"); +} + +foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = Terminal::fromStreams($pipes[0]); +$timeout = Duration::fromSeconds(1); + +foreach (range(1, 4) as $_) { + $key = $terminal->readKey($timeout); + + if ($key instanceof Key) { + echo $key->name, "\n"; + } elseif (is_string($key)) { + echo bin2hex($key), "\n"; + } else { + echo "false\n"; + } +} + +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } +} +proc_close($proc); +?> +--EXPECT-- +1b5b313b3541 +1b5b327e +F1 +Tab From c684f559cd2f736f3ed2ee0669148aad2288b491 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 19:20:26 +0545 Subject: [PATCH 09/38] Preserve stream validation errors --- ext/standard/io_terminal.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index c9c9844964af..01b4b2c2d081 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1528,14 +1528,18 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) ZEND_PARSE_PARAMETERS_END(); if (!php_io_terminal_stream_target_init(input_arg, true, &target) || target.php_stream == NULL) { - zend_argument_value_error(1, "must be a valid stream resource"); + if (!EG(exception)) { + zend_argument_value_error(1, "must be a valid stream resource"); + } RETURN_THROWS(); } if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { php_io_terminal_stream_target out_target; if (!php_io_terminal_stream_target_init(output_arg, false, &out_target) || out_target.php_stream == NULL) { - zend_argument_value_error(2, "must be a valid stream resource or null"); + if (!EG(exception)) { + zend_argument_value_error(2, "must be a valid stream resource or null"); + } RETURN_THROWS(); } } From e826bab14121624c9bdfcb0422bd9a6a50a196eb Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 19:21:30 +0545 Subject: [PATCH 10/38] Keep unsupported terminal sequences raw --- ext/standard/io_terminal.c | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 01b4b2c2d081..519c56e747d4 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1182,11 +1182,10 @@ static zend_string *php_io_terminal_key_from_escape_sequence( if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return zend_string_init("delete", sizeof("delete") - 1, false); if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return zend_string_init("pageup", sizeof("pageup") - 1, false); if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return zend_string_init("pagedown", sizeof("pagedown") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return zend_string_init("tab", sizeof("tab") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[P") || PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return zend_string_init("f1", sizeof("f1") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[Q") || PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return zend_string_init("f2", sizeof("f2") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[R") || PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return zend_string_init("f3", sizeof("f3") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[S") || PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return zend_string_init("f4", sizeof("f4") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return zend_string_init("f1", sizeof("f1") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return zend_string_init("f2", sizeof("f2") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return zend_string_init("f3", sizeof("f3") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return zend_string_init("f4", sizeof("f4") - 1, false); if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return zend_string_init("f5", sizeof("f5") - 1, false); if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return zend_string_init("f6", sizeof("f6") - 1, false); if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return zend_string_init("f7", sizeof("f7") - 1, false); From c5a5de907a491bf95a91955359ce2137ca5a1857 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 19:21:43 +0545 Subject: [PATCH 11/38] Keep terminal sequence test within defined keys --- ext/standard/tests/terminal/terminal_read_key_sequences.phpt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt index 26b1bfe96bdd..f57cc268c41a 100644 --- a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt @@ -42,7 +42,7 @@ use Io\Terminal\Key; use Io\Terminal\Terminal; use Time\Duration; -$payload = "\x1b[1;5A\x1b[2~\x1b[P\x1b[Z"; +$payload = "\x1b[1;5A\x1b[2~\x1bOP\x1b[A"; $code = 'fwrite(STDOUT, ' . var_export($payload, true) . ');'; $proc = proc_open( @@ -82,4 +82,4 @@ proc_close($proc); 1b5b313b3541 1b5b327e F1 -Tab +Up From fd56ef76671cb0a18ee732d5ad3b183d0ef7be61 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 19:22:28 +0545 Subject: [PATCH 12/38] Reset queued input before secret reads --- ext/standard/io_terminal.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 519c56e747d4..282c19593e98 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1764,6 +1764,8 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); #ifdef PHP_WIN32 + memset(&intern->pending_key, 0, sizeof(intern->pending_key)); + intern->pending_key_high_surrogate = 0; intern->pending_high_surrogate = 0; #else memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); From 9a0ec24f9db2deb50067c7adf30e0303740ad73e Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 20:11:47 +0545 Subject: [PATCH 13/38] Address terminal review feedback --- ext/standard/io_poll.c | 2 +- ext/standard/io_poll.h | 2 + ext/standard/io_terminal.c | 304 +++++++++--------- ext/standard/io_terminal.stub.php | 2 + ext/standard/io_terminal_arginfo.h | 12 +- ext/standard/io_terminal_decl.h | 8 +- .../tests/terminal/terminal_create.phpt | 38 +-- .../tests/terminal/terminal_raw_mode.phpt | 8 +- .../tests/terminal/terminal_read_key.phpt | 16 +- .../tests/terminal/terminal_read_secret.phpt | 16 +- 10 files changed, 218 insertions(+), 190 deletions(-) diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index dd0b27ae0067..fceb043ad98f 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -28,7 +28,7 @@ static zend_class_entry *php_io_poll_event_class_entry; static zend_class_entry *php_io_poll_context_class_entry; static zend_class_entry *php_io_poll_watcher_class_entry; static zend_class_entry *php_io_poll_handle_class_entry; -static zend_class_entry *php_io_exception_class_entry; +PHPAPI zend_class_entry *php_io_exception_class_entry; static zend_class_entry *php_io_poll_exception_class_entry; static zend_class_entry *php_io_poll_failed_backend_unavailable_class_entry; static zend_class_entry *php_io_poll_failed_operation_class_entry; diff --git a/ext/standard/io_poll.h b/ext/standard/io_poll.h index 1b94355f27a5..00ee5e6aa5ee 100644 --- a/ext/standard/io_poll.h +++ b/ext/standard/io_poll.h @@ -19,6 +19,8 @@ BEGIN_EXTERN_C() +PHPAPI extern zend_class_entry *php_io_exception_class_entry; + PHPAPI void php_io_poll_stream_notify_close(php_stream *stream); END_EXTERN_C() diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 282c19593e98..df12e04a91c9 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -17,10 +17,11 @@ #include "zend_exceptions.h" #include "zend_smart_str.h" #include "php_network.h" +#include "php_poll.h" #include "php_streams.h" #include "ext/standard/basic_functions.h" #include "ext/date/php_time.h" -#include "ext/spl/spl_exceptions.h" +#include "io_poll.h" #include "io_terminal.h" #include "io_terminal_decl.h" #include "io_terminal_arginfo.h" @@ -36,9 +37,7 @@ # include #else # include -# include # include -# include # include # include # include @@ -102,6 +101,7 @@ typedef struct php_io_terminal_stream_target { bool is_default; } php_io_terminal_stream_target; +static zend_class_entry *php_io_terminal_exception_ce; static zend_class_entry *php_io_terminal_key_ce; static zend_class_entry *php_io_terminal_terminal_size_ce; static zend_class_entry *php_io_terminal_mode_token_ce; @@ -542,55 +542,55 @@ static zend_string *php_io_terminal_key_from_virtual_key(WORD vk) { switch (vk) { case VK_UP: - return zend_string_init("up", sizeof("up") - 1, false); + return ZSTR_INIT_LITERAL("up", false); case VK_DOWN: - return zend_string_init("down", sizeof("down") - 1, false); + return ZSTR_INIT_LITERAL("down", false); case VK_RIGHT: - return zend_string_init("right", sizeof("right") - 1, false); + return ZSTR_INIT_LITERAL("right", false); case VK_LEFT: - return zend_string_init("left", sizeof("left") - 1, false); + return ZSTR_INIT_LITERAL("left", false); case VK_RETURN: - return zend_string_init("enter", sizeof("enter") - 1, false); + return ZSTR_INIT_LITERAL("enter", false); case VK_BACK: - return zend_string_init("backspace", sizeof("backspace") - 1, false); + return ZSTR_INIT_LITERAL("backspace", false); case VK_ESCAPE: - return zend_string_init("escape", sizeof("escape") - 1, false); + return ZSTR_INIT_LITERAL("escape", false); case VK_TAB: - return zend_string_init("tab", sizeof("tab") - 1, false); + return ZSTR_INIT_LITERAL("tab", false); case VK_HOME: - return zend_string_init("home", sizeof("home") - 1, false); + return ZSTR_INIT_LITERAL("home", false); case VK_END: - return zend_string_init("end", sizeof("end") - 1, false); + return ZSTR_INIT_LITERAL("end", false); case VK_DELETE: - return zend_string_init("delete", sizeof("delete") - 1, false); + return ZSTR_INIT_LITERAL("delete", false); case VK_PRIOR: - return zend_string_init("pageup", sizeof("pageup") - 1, false); + return ZSTR_INIT_LITERAL("pageup", false); case VK_NEXT: - return zend_string_init("pagedown", sizeof("pagedown") - 1, false); + return ZSTR_INIT_LITERAL("pagedown", false); case VK_F1: - return zend_string_init("f1", sizeof("f1") - 1, false); + return ZSTR_INIT_LITERAL("f1", false); case VK_F2: - return zend_string_init("f2", sizeof("f2") - 1, false); + return ZSTR_INIT_LITERAL("f2", false); case VK_F3: - return zend_string_init("f3", sizeof("f3") - 1, false); + return ZSTR_INIT_LITERAL("f3", false); case VK_F4: - return zend_string_init("f4", sizeof("f4") - 1, false); + return ZSTR_INIT_LITERAL("f4", false); case VK_F5: - return zend_string_init("f5", sizeof("f5") - 1, false); + return ZSTR_INIT_LITERAL("f5", false); case VK_F6: - return zend_string_init("f6", sizeof("f6") - 1, false); + return ZSTR_INIT_LITERAL("f6", false); case VK_F7: - return zend_string_init("f7", sizeof("f7") - 1, false); + return ZSTR_INIT_LITERAL("f7", false); case VK_F8: - return zend_string_init("f8", sizeof("f8") - 1, false); + return ZSTR_INIT_LITERAL("f8", false); case VK_F9: - return zend_string_init("f9", sizeof("f9") - 1, false); + return ZSTR_INIT_LITERAL("f9", false); case VK_F10: - return zend_string_init("f10", sizeof("f10") - 1, false); + return ZSTR_INIT_LITERAL("f10", false); case VK_F11: - return zend_string_init("f11", sizeof("f11") - 1, false); + return ZSTR_INIT_LITERAL("f11", false); case VK_F12: - return zend_string_init("f12", sizeof("f12") - 1, false); + return ZSTR_INIT_LITERAL("f12", false); default: return NULL; } @@ -716,7 +716,7 @@ static zend_string *php_io_terminal_read_stream_key( } if (record.EventType == WINDOW_BUFFER_SIZE_EVENT) { - result = zend_string_init("resize", sizeof("resize") - 1, false); + result = ZSTR_INIT_LITERAL("resize", false); break; } @@ -847,7 +847,7 @@ static zend_string *php_io_terminal_read_stream_secret( } if (success && !failed) { - return secret.s != NULL ? smart_str_extract(&secret) : zend_empty_string; + return smart_str_extract(&secret); } smart_str_free(&secret); @@ -945,78 +945,65 @@ static void php_io_terminal_buffer_remove_last_utf8_char(smart_str *secret) ZSTR_LEN(secret->s) = len; } -static void php_io_terminal_duration_to_timeval(const php_date_time_duration *duration, struct timeval *timeout) -{ - uint64_t seconds = duration->duration.seconds; - uint64_t microseconds = ((uint64_t) duration->duration.nanoseconds + 999) / 1000; - - if (seconds > (uint64_t) LONG_MAX) { - timeout->tv_sec = LONG_MAX; - timeout->tv_usec = 999999; - return; - } - - timeout->tv_sec = (long) seconds; - timeout->tv_usec = (long) microseconds; - - if (timeout->tv_usec >= 1000000) { - if (timeout->tv_sec < LONG_MAX) { - timeout->tv_sec++; - timeout->tv_usec = 0; - } else { - timeout->tv_usec = 999999; - } - } -} - -static bool php_io_terminal_timeval_subtract_elapsed(struct timeval *remaining, zend_hrtime_t elapsed_ns) +static bool php_io_terminal_timespec_subtract_elapsed(struct timespec *remaining, zend_hrtime_t elapsed_ns) { uint64_t elapsed_seconds = elapsed_ns / ZEND_NANO_IN_SEC; - uint64_t elapsed_microseconds = (elapsed_ns % ZEND_NANO_IN_SEC + 999) / 1000; - - if (elapsed_microseconds >= 1000000) { - elapsed_seconds++; - elapsed_microseconds -= 1000000; - } + uint64_t elapsed_nanoseconds = elapsed_ns % ZEND_NANO_IN_SEC; if (elapsed_seconds > (uint64_t) remaining->tv_sec || (elapsed_seconds == (uint64_t) remaining->tv_sec - && elapsed_microseconds >= (uint64_t) remaining->tv_usec)) { + && elapsed_nanoseconds >= (uint64_t) remaining->tv_nsec)) { remaining->tv_sec = 0; - remaining->tv_usec = 0; + remaining->tv_nsec = 0; return false; } - remaining->tv_sec -= (long) elapsed_seconds; - if ((uint64_t) remaining->tv_usec < elapsed_microseconds) { + remaining->tv_sec -= (time_t) elapsed_seconds; + if ((uint64_t) remaining->tv_nsec < elapsed_nanoseconds) { remaining->tv_sec--; - remaining->tv_usec += 1000000; + remaining->tv_nsec += ZEND_NANO_IN_SEC; } - remaining->tv_usec -= (long) elapsed_microseconds; + remaining->tv_nsec -= (long) elapsed_nanoseconds; return true; } +static php_poll_ctx *php_io_terminal_create_poll_context(int fd) +{ + php_poll_ctx *poll_ctx = php_poll_create(PHP_POLL_BACKEND_AUTO, 0); + + if (poll_ctx == NULL) { + return NULL; + } + + if (php_poll_set_max_events_hint(poll_ctx, 1) == FAILURE + || php_poll_init(poll_ctx) == FAILURE + || php_poll_add(poll_ctx, fd, PHP_POLL_READ, NULL) == FAILURE) { + php_poll_destroy(poll_ctx); + return NULL; + } + + return poll_ctx; +} + static int php_io_terminal_read_byte( int fd, php_stream *stream, + php_poll_ctx *poll_ctx, unsigned char *byte, - const struct timeval *timeout, + const struct timespec *timeout, bool allow_resize, const sig_atomic_t *generation ) { - fd_set read_fds; - struct timeval remaining; - int select_result; + struct timespec remaining; ssize_t bytes_read; if (stream != NULL && stream->writepos > stream->readpos) { return php_stream_read(stream, (char *) byte, 1) == 1 ? 1 : -1; } - if (fd < 0 || fd >= FD_SETSIZE) { - errno = EINVAL; + if (poll_ctx == NULL) { return -1; } @@ -1025,8 +1012,8 @@ static int php_io_terminal_read_byte( } for (;;) { - struct timeval wait_time; - struct timeval *wait_time_ptr = NULL; + php_poll_event event; + const struct timespec *wait_timeout = NULL; zend_hrtime_t start_ns = 0; #if defined(SIGWINCH) @@ -1035,25 +1022,20 @@ static int php_io_terminal_read_byte( } #endif - FD_ZERO(&read_fds); - FD_SET(fd, &read_fds); - if (timeout != NULL) { - wait_time = remaining; - wait_time_ptr = &wait_time; + wait_timeout = &remaining; start_ns = zend_hrtime(); } - select_result = select(fd + 1, &read_fds, NULL, NULL, wait_time_ptr); - if (select_result > 0) { + int poll_result = php_poll_wait(poll_ctx, &event, 1, wait_timeout); + if (poll_result > 0) { break; } - - if (select_result == 0) { + if (poll_result == 0) { return 0; } - if (errno == EINTR) { + if (php_poll_get_error(poll_ctx) == PHP_POLL_ERR_INTERRUPTED) { #if defined(SIGWINCH) if (allow_resize && generation != NULL && *generation != php_io_terminal_resize_generation) { return PHP_IO_TERMINAL_READ_RESIZE; @@ -1062,7 +1044,7 @@ static int php_io_terminal_read_byte( if (timeout != NULL) { zend_hrtime_t elapsed_ns = zend_hrtime() - start_ns; if (UNEXPECTED(elapsed_ns == 0) - || !php_io_terminal_timeval_subtract_elapsed(&remaining, elapsed_ns)) { + || !php_io_terminal_timespec_subtract_elapsed(&remaining, elapsed_ns)) { return 0; } } @@ -1086,17 +1068,18 @@ static int php_io_terminal_read_byte( static zend_string *php_io_terminal_finish_utf8_sequence( int fd, php_stream *stream, + php_poll_ctx *poll_ctx, php_io_terminal_utf8_pending *pending, - const struct timeval *first_timeout, - const struct timeval *sequence_timeout + const struct timespec *first_timeout, + const struct timespec *sequence_timeout ) { size_t initial_length = pending->length; while (pending->length < pending->expected) { unsigned char key; - const struct timeval *timeout = pending->length == initial_length ? first_timeout : sequence_timeout; - int result = php_io_terminal_read_byte(fd, stream, &key, timeout, false, NULL); + const struct timespec *timeout = pending->length == initial_length ? first_timeout : sequence_timeout; + int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); if (result != 1) { return NULL; @@ -1118,8 +1101,9 @@ static zend_string *php_io_terminal_finish_utf8_sequence( static zend_string *php_io_terminal_key_from_utf8_sequence( int fd, php_stream *stream, + php_poll_ctx *poll_ctx, unsigned char key, - const struct timeval *sequence_timeout, + const struct timespec *sequence_timeout, php_io_terminal_utf8_pending *pending ) { @@ -1134,13 +1118,14 @@ static zend_string *php_io_terminal_key_from_utf8_sequence( pending->length = 1; pending->expected = sequence_len; - return php_io_terminal_finish_utf8_sequence(fd, stream, pending, sequence_timeout, sequence_timeout); + return php_io_terminal_finish_utf8_sequence(fd, stream, poll_ctx, pending, sequence_timeout, sequence_timeout); } static zend_string *php_io_terminal_key_from_escape_sequence( int fd, php_stream *stream, - const struct timeval *sequence_timeout + php_poll_ctx *poll_ctx, + const struct timespec *sequence_timeout ) { unsigned char seq[32]; @@ -1149,9 +1134,9 @@ static zend_string *php_io_terminal_key_from_escape_sequence( seq[seq_len++] = 0x1b; - read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], sequence_timeout, false, NULL); if (read_result != 1) { - return zend_string_init("escape", sizeof("escape") - 1, false); + return ZSTR_INIT_LITERAL("escape", false); } seq_len++; @@ -1159,7 +1144,7 @@ static zend_string *php_io_terminal_key_from_escape_sequence( while (seq_len < sizeof(seq)) { unsigned char next; - read_result = php_io_terminal_read_byte(fd, stream, &next, sequence_timeout, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, sequence_timeout, false, NULL); if (read_result != 1) { break; } @@ -1173,40 +1158,40 @@ static zend_string *php_io_terminal_key_from_escape_sequence( #define PHP_IO_TERMINAL_CSI_IS(literal) \ (seq_len == sizeof(literal) - 1 && memcmp(seq, literal, sizeof(literal) - 1) == 0) - if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return zend_string_init("up", sizeof("up") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return zend_string_init("down", sizeof("down") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return zend_string_init("right", sizeof("right") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return zend_string_init("left", sizeof("left") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[H") || PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return zend_string_init("home", sizeof("home") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[F") || PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return zend_string_init("end", sizeof("end") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return zend_string_init("delete", sizeof("delete") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return zend_string_init("pageup", sizeof("pageup") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return zend_string_init("pagedown", sizeof("pagedown") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return zend_string_init("f1", sizeof("f1") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return zend_string_init("f2", sizeof("f2") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return zend_string_init("f3", sizeof("f3") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return zend_string_init("f4", sizeof("f4") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return zend_string_init("f5", sizeof("f5") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return zend_string_init("f6", sizeof("f6") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return zend_string_init("f7", sizeof("f7") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return zend_string_init("f8", sizeof("f8") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return zend_string_init("f9", sizeof("f9") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return zend_string_init("f10", sizeof("f10") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return zend_string_init("f11", sizeof("f11") - 1, false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return zend_string_init("f12", sizeof("f12") - 1, false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return ZSTR_INIT_LITERAL("up", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return ZSTR_INIT_LITERAL("down", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return ZSTR_INIT_LITERAL("right", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return ZSTR_INIT_LITERAL("left", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[H") || PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return ZSTR_INIT_LITERAL("home", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[F") || PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return ZSTR_INIT_LITERAL("end", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return ZSTR_INIT_LITERAL("delete", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return ZSTR_INIT_LITERAL("pageup", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return ZSTR_INIT_LITERAL("pagedown", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return ZSTR_INIT_LITERAL("f1", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return ZSTR_INIT_LITERAL("f2", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return ZSTR_INIT_LITERAL("f3", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return ZSTR_INIT_LITERAL("f4", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return ZSTR_INIT_LITERAL("f5", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return ZSTR_INIT_LITERAL("f6", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return ZSTR_INIT_LITERAL("f7", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return ZSTR_INIT_LITERAL("f8", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return ZSTR_INIT_LITERAL("f9", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return ZSTR_INIT_LITERAL("f10", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return ZSTR_INIT_LITERAL("f11", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return ZSTR_INIT_LITERAL("f12", false); #undef PHP_IO_TERMINAL_CSI_IS } else if (seq[1] == 'O' && seq_len < sizeof(seq)) { - read_result = php_io_terminal_read_byte(fd, stream, &seq[seq_len], sequence_timeout, false, NULL); + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], sequence_timeout, false, NULL); if (read_result == 1) { seq_len++; switch (seq[2]) { - case 'P': return zend_string_init("f1", sizeof("f1") - 1, false); - case 'Q': return zend_string_init("f2", sizeof("f2") - 1, false); - case 'R': return zend_string_init("f3", sizeof("f3") - 1, false); - case 'S': return zend_string_init("f4", sizeof("f4") - 1, false); - case 'H': return zend_string_init("home", sizeof("home") - 1, false); - case 'F': return zend_string_init("end", sizeof("end") - 1, false); + case 'P': return ZSTR_INIT_LITERAL("f1", false); + case 'Q': return ZSTR_INIT_LITERAL("f2", false); + case 'R': return ZSTR_INIT_LITERAL("f3", false); + case 'S': return ZSTR_INIT_LITERAL("f4", false); + case 'H': return ZSTR_INIT_LITERAL("home", false); + case 'F': return ZSTR_INIT_LITERAL("end", false); } } } @@ -1217,32 +1202,33 @@ static zend_string *php_io_terminal_key_from_escape_sequence( static zend_string *php_io_terminal_key_from_byte( int fd, php_stream *stream, + php_poll_ctx *poll_ctx, unsigned char key, - const struct timeval *sequence_timeout, + const struct timespec *sequence_timeout, php_io_terminal_utf8_pending *pending ) { switch (key) { case '\r': case '\n': - return zend_string_init("enter", sizeof("enter") - 1, false); + return ZSTR_INIT_LITERAL("enter", false); case '\t': - return zend_string_init("tab", sizeof("tab") - 1, false); + return ZSTR_INIT_LITERAL("tab", false); case 0x7f: case '\b': - return zend_string_init("backspace", sizeof("backspace") - 1, false); + return ZSTR_INIT_LITERAL("backspace", false); case 0x1b: - return php_io_terminal_key_from_escape_sequence(fd, stream, sequence_timeout); + return php_io_terminal_key_from_escape_sequence(fd, stream, poll_ctx, sequence_timeout); default: - return php_io_terminal_key_from_utf8_sequence(fd, stream, key, sequence_timeout, pending); + return php_io_terminal_key_from_utf8_sequence(fd, stream, poll_ctx, key, sequence_timeout, pending); } } static zend_string *php_io_terminal_read_stream_key( php_io_terminal_native_stream input, php_stream *stream, - const struct timeval *timeout, - const struct timeval *sequence_timeout, + const struct timespec *timeout, + const struct timespec *sequence_timeout, php_io_terminal_utf8_pending *pending ) { @@ -1255,11 +1241,17 @@ static zend_string *php_io_terminal_read_stream_key( bool mode_changed; sig_atomic_t resize_generation = 0; bool resize_handler_installed; + php_poll_ctx *poll_ctx; if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { return NULL; } + poll_ctx = php_io_terminal_create_poll_context(fd); + if (poll_ctx == NULL) { + return NULL; + } + #if defined(SIGWINCH) resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); #else @@ -1276,18 +1268,19 @@ static zend_string *php_io_terminal_read_stream_key( php_io_terminal_restore_resize_handler(); } #endif + php_poll_destroy(poll_ctx); return NULL; } if (pending->length > 0) { - result = php_io_terminal_finish_utf8_sequence(fd, stream, pending, timeout, sequence_timeout); + result = php_io_terminal_finish_utf8_sequence(fd, stream, poll_ctx, pending, timeout, sequence_timeout); } else { - read_result = php_io_terminal_read_byte(fd, stream, &key, timeout, true, + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true, resize_handler_installed ? &resize_generation : NULL); if (read_result == PHP_IO_TERMINAL_READ_RESIZE) { - result = zend_string_init("resize", sizeof("resize") - 1, false); + result = ZSTR_INIT_LITERAL("resize", false); } else if (read_result == 1) { - result = php_io_terminal_key_from_byte(fd, stream, key, sequence_timeout, pending); + result = php_io_terminal_key_from_byte(fd, stream, poll_ctx, key, sequence_timeout, pending); } } @@ -1300,6 +1293,7 @@ static zend_string *php_io_terminal_read_stream_key( if (result != NULL) { zend_string_release(result); } + php_poll_destroy(poll_ctx); return NULL; } @@ -1309,34 +1303,42 @@ static zend_string *php_io_terminal_read_stream_key( } #endif + php_poll_destroy(poll_ctx); return result; } static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_stream input, php_stream *stream) { - const struct timeval sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000}; + const struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; int fd = input; struct termios mode; struct termios raw_mode; smart_str secret = {0}; bool success = false; bool mode_changed; + php_poll_ctx *poll_ctx; if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { return NULL; } + poll_ctx = php_io_terminal_create_poll_context(fd); + if (poll_ctx == NULL) { + return NULL; + } + raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + php_poll_destroy(poll_ctx); return NULL; } for (;;) { unsigned char key; - int result = php_io_terminal_read_byte(fd, stream, &key, NULL, false, NULL); + int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, NULL, false, NULL); if (result != 1) { break; } @@ -1355,7 +1357,7 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st goto restore; case 0x1b: { - zend_string *escape_key = php_io_terminal_key_from_escape_sequence(fd, stream, &sequence_timeout); + zend_string *escape_key = php_io_terminal_key_from_escape_sequence(fd, stream, poll_ctx, &sequence_timeout); bool is_escape = zend_string_equals_literal(escape_key, "escape"); zend_string_release(escape_key); if (is_escape) { @@ -1373,7 +1375,7 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st size_t i; seq[0] = key; for (i = 1; i < seq_len; i++) { - if (php_io_terminal_read_byte(fd, stream, &seq[i], &sequence_timeout, false, NULL) != 1) { + if (php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], &sequence_timeout, false, NULL) != 1) { break; } } @@ -1389,8 +1391,10 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st success = false; } + php_poll_destroy(poll_ctx); + if (success) { - return secret.s != NULL ? smart_str_extract(&secret) : zend_empty_string; + return smart_str_extract(&secret); } smart_str_free(&secret); @@ -1718,17 +1722,19 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) &intern->pending_key_high_surrogate ); #else - struct timeval timeout; - struct timeval sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000}; - const struct timeval *timeout_ptr = NULL; + struct timespec timeout; + struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; + const struct timespec *timeout_ptr = NULL; if (timeout_duration != NULL) { - php_io_terminal_duration_to_timeval(timeout_duration, &timeout); + timeout.tv_sec = timeout_duration->duration.seconds; + timeout.tv_nsec = timeout_duration->duration.nanoseconds; timeout_ptr = &timeout; } if (seq_timeout_duration != NULL) { - php_io_terminal_duration_to_timeval(seq_timeout_duration, &sequence_timeout); + sequence_timeout.tv_sec = seq_timeout_duration->duration.seconds; + sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; } key = php_io_terminal_read_stream_key( @@ -1789,12 +1795,16 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) RETURN_STR(secret); } - zend_throw_exception(spl_ce_RuntimeException, "Unable to read secret from terminal", 0); + zend_throw_exception(php_io_terminal_exception_ce, "Unable to read secret from terminal", 0); RETURN_THROWS(); } PHP_MINIT_FUNCTION(terminal) { + ZEND_ASSERT(php_io_exception_class_entry != NULL); + php_io_terminal_exception_ce + = register_class_Io_Terminal_TerminalException(php_io_exception_class_entry); + php_io_terminal_key_ce = register_class_Io_Terminal_Key(); php_io_terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index 2c31da852f82..96ab3f76ddfa 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -7,6 +7,8 @@ namespace Io\Terminal { + class TerminalException extends \Io\IoException {} + enum Key { case Up; diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 823e68fa42d3..fdccda32d34e 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 5f5105fa50433e16d7a863f58002ad62916fc35e + * Stub hash: 44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0 * Has decl header: yes */ #include "zend_enum.h" @@ -70,6 +70,16 @@ static const zend_function_entry class_Io_Terminal_Terminal_methods[] = { ZEND_FE_END }; +static zend_class_entry *register_class_Io_Terminal_TerminalException(zend_class_entry *class_entry_Io_IoException) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "TerminalException", NULL); + class_entry = zend_register_internal_class_with_flags(&ce, class_entry_Io_IoException, 0); + + return class_entry; +} + static zend_class_entry *register_class_Io_Terminal_Key(void) { zend_class_entry *class_entry = zend_register_internal_enum("Io\\Terminal\\Key", IS_UNDEF, NULL); diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index 7258312f3275..d21912b8cb7f 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 5f5105fa50433e16d7a863f58002ad62916fc35e */ + * Stub hash: 44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0 */ -#ifndef ZEND_IO_TERMINAL_DECL_5f5105fa50433e16d7a863f58002ad62916fc35e_H -#define ZEND_IO_TERMINAL_DECL_5f5105fa50433e16d7a863f58002ad62916fc35e_H +#ifndef ZEND_IO_TERMINAL_DECL_44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0_H +#define ZEND_IO_TERMINAL_DECL_44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_5f5105fa50433e16d7a863f58002ad62916fc35e_H */ +#endif /* ZEND_IO_TERMINAL_DECL_44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0_H */ diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index ff2b1e7ca099..9891da2c79ac 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -22,22 +22,22 @@ var_dump($t3 instanceof Terminal); // Invalid stream arguments try { Terminal::fromStreams('invalid'); -} catch (TypeError $e) { - echo $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } try { Terminal::fromStreams($fp, 123); -} catch (TypeError $e) { - echo $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } // Constructor privacy foreach ([Terminal::class, TerminalSize::class, ModeToken::class] as $class) { try { new $class(); - } catch (Error $e) { - echo "Private constructor: " . $class . "\n"; + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } } @@ -45,28 +45,28 @@ foreach ([Terminal::class, TerminalSize::class, ModeToken::class] as $class) { foreach ([$t1, $t2] as $obj) { try { clone $obj; - } catch (Error $e) { - echo "Clone rejected: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } } // Serialization rejection try { serialize($t1); -} catch (Exception $e) { - echo "Serialize rejected: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } ?> ---EXPECT-- +--EXPECTF-- bool(true) bool(true) bool(true) -Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given -Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given -Private constructor: Io\Terminal\Terminal -Private constructor: Io\Terminal\TerminalSize -Private constructor: Io\Terminal\ModeToken -Clone rejected: Trying to clone an uncloneable object of class Io\Terminal\Terminal -Clone rejected: Trying to clone an uncloneable object of class Io\Terminal\Terminal -Serialize rejected: Serialization of 'Io\Terminal\Terminal' is not allowed +TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given +TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given +Error: %s +Error: %s +Error: %s +Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal +Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal +Exception: Serialization of 'Io\Terminal\Terminal' is not allowed diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt index 2d68bf7f02fa..b405c3415454 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -25,8 +25,8 @@ if ($token instanceof ModeToken) { try { $terminal->restoreMode($token); echo "FAIL: stale token was accepted\n"; - } catch (ValueError $e) { - echo "Stale token: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } // Session-managed restore without args @@ -38,7 +38,7 @@ if ($token instanceof ModeToken) { // Non-interactive runner fallback check echo "enableRawMode: ModeToken\n"; echo "bool(true)\n"; - echo "Stale token: Io\Terminal\Terminal::restoreMode(): Argument #1 (\$mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode()\n"; + echo "ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 (\$mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode()\n"; echo "bool(true)\n"; echo "bool(true)\n"; echo "bool(false)\n"; @@ -49,7 +49,7 @@ if ($token instanceof ModeToken) { bool(false) enableRawMode: ModeToken bool(true) -Stale token: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() bool(true) bool(true) bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt index fe117333c73b..834b55c8a816 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -24,19 +24,19 @@ if (method_exists($d1, 'sub')) { try { $t->readKey($neg); echo "FAIL: accepted negative timeout\n"; - } catch (ValueError $e) { - echo "Negative timeout: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } try { $t->readKey(Duration::fromSeconds(0), $neg); echo "FAIL: accepted negative sequence timeout\n"; - } catch (ValueError $e) { - echo "Negative sequenceTimeout: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } } else { - echo "Negative timeout: Io\Terminal\Terminal::readKey(): Argument #1 (\$timeout) must not be negative\n"; - echo "Negative sequenceTimeout: Io\Terminal\Terminal::readKey(): Argument #2 (\$sequenceTimeout) must not be negative\n"; + echo "ValueError: Io\Terminal\Terminal::readKey(): Argument #1 (\$timeout) must not be negative\n"; + echo "ValueError: Io\Terminal\Terminal::readKey(): Argument #2 (\$sequenceTimeout) must not be negative\n"; } // Non-terminal stream returns false immediately @@ -48,6 +48,6 @@ var_dump($nonTty->readKey(Duration::fromSeconds(0))); --EXPECTF-- bool(true) bool(true) -Negative timeout: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative -Negative sequenceTimeout: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt index b6a1801c6b82..b460402e9829 100644 --- a/ext/standard/tests/terminal/terminal_read_secret.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -4,27 +4,31 @@ Io\Terminal\Terminal: readSecret parameter count and non-tty failure handling readSecret(); echo "FAIL: readSecret succeeded on non-tty\n"; -} catch (RuntimeException $e) { - echo "RuntimeException: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } // readSecret takes 0 arguments try { $nonTty->readSecret('prompt'); echo "FAIL: readSecret accepted arguments\n"; -} catch (ArgumentCountError $e) { - echo "ArgumentCountError: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } ?> --EXPECTF-- -RuntimeException: Unable to read secret from terminal +bool(true) +Io\Terminal\TerminalException: Unable to read secret from terminal ArgumentCountError: Io\Terminal\Terminal::readSecret() expects exactly 0 arguments, 1 given From a62c87a0972c352c70d3746a786fbb3c4b7b83a8 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 20:14:25 +0545 Subject: [PATCH 14/38] Address remaining terminal review feedback --- ext/standard/io_terminal.c | 4 --- ext/standard/io_terminal.stub.php | 4 +++ ext/standard/io_terminal_arginfo.h | 10 +++--- ext/standard/io_terminal_decl.h | 8 ++--- .../tests/terminal/terminal_key_enum.phpt | 32 ----------------- .../tests/terminal/terminal_read_key.phpt | 34 ++++++++----------- 6 files changed, 27 insertions(+), 65 deletions(-) delete mode 100644 ext/standard/tests/terminal/terminal_key_enum.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index df12e04a91c9..4972d980f67e 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1811,8 +1811,6 @@ PHP_MINIT_FUNCTION(terminal) php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); php_io_terminal_mode_token_ce->create_object = php_io_terminal_mode_token_create_object; - php_io_terminal_mode_token_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES; - memcpy(&php_io_terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_mode_token_handlers)); php_io_terminal_mode_token_handlers.offset = offsetof(php_io_terminal_mode_token_object, std); php_io_terminal_mode_token_handlers.free_obj = php_io_terminal_mode_token_free_obj; @@ -1820,8 +1818,6 @@ PHP_MINIT_FUNCTION(terminal) php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(); php_io_terminal_terminal_ce->create_object = php_io_terminal_create_object; - php_io_terminal_terminal_ce->ce_flags |= ZEND_ACC_NO_DYNAMIC_PROPERTIES; - memcpy(&php_io_terminal_object_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_object_handlers)); php_io_terminal_object_handlers.offset = offsetof(php_io_terminal_object, std); php_io_terminal_object_handlers.free_obj = php_io_terminal_free_obj; diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index 96ab3f76ddfa..5346d0513427 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -7,6 +7,7 @@ namespace Io\Terminal { + /** @strict-properties */ class TerminalException extends \Io\IoException {} enum Key @@ -39,6 +40,7 @@ enum Key case F12; } + /** @strict-properties */ final readonly class TerminalSize { public readonly int $cols; @@ -48,6 +50,7 @@ private function __construct() {} } /** + * @strict-properties * @not-serializable */ final class ModeToken @@ -56,6 +59,7 @@ private function __construct() {} } /** + * @strict-properties * @not-serializable */ final class Terminal diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index fdccda32d34e..43d01afd90c0 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0 + * Stub hash: d469eb6a85a1b785b0f9df0657696de108706101 * Has decl header: yes */ #include "zend_enum.h" @@ -75,7 +75,7 @@ static zend_class_entry *register_class_Io_Terminal_TerminalException(zend_class zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "TerminalException", NULL); - class_entry = zend_register_internal_class_with_flags(&ce, class_entry_Io_IoException, 0); + class_entry = zend_register_internal_class_with_flags(&ce, class_entry_Io_IoException, ZEND_ACC_NO_DYNAMIC_PROPERTIES); return class_entry; } @@ -144,7 +144,7 @@ static zend_class_entry *register_class_Io_Terminal_TerminalSize(void) zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "TerminalSize", class_Io_Terminal_TerminalSize_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_READONLY_CLASS); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_READONLY_CLASS); zval property_cols_default_value; ZVAL_UNDEF(&property_cols_default_value); @@ -166,7 +166,7 @@ static zend_class_entry *register_class_Io_Terminal_ModeToken(void) zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeToken", class_Io_Terminal_ModeToken_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NOT_SERIALIZABLE); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); return class_entry; } @@ -176,7 +176,7 @@ static zend_class_entry *register_class_Io_Terminal_Terminal(void) zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "Terminal", class_Io_Terminal_Terminal_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NOT_SERIALIZABLE); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); return class_entry; } diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index d21912b8cb7f..f3ab4852e3f9 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0 */ + * Stub hash: d469eb6a85a1b785b0f9df0657696de108706101 */ -#ifndef ZEND_IO_TERMINAL_DECL_44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0_H -#define ZEND_IO_TERMINAL_DECL_44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0_H +#ifndef ZEND_IO_TERMINAL_DECL_d469eb6a85a1b785b0f9df0657696de108706101_H +#define ZEND_IO_TERMINAL_DECL_d469eb6a85a1b785b0f9df0657696de108706101_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_44e9f2d5b2b59a43ad0c56c0cff90b796f40eea0_H */ +#endif /* ZEND_IO_TERMINAL_DECL_d469eb6a85a1b785b0f9df0657696de108706101_H */ diff --git a/ext/standard/tests/terminal/terminal_key_enum.phpt b/ext/standard/tests/terminal/terminal_key_enum.phpt deleted file mode 100644 index 7dfb2d10b863..000000000000 --- a/ext/standard/tests/terminal/terminal_key_enum.phpt +++ /dev/null @@ -1,32 +0,0 @@ ---TEST-- -Io\Terminal\Key: enum cases and identity ---FILE-- - $c->name, $cases); -var_dump($names === $expected); - -// Key identity and comparison -var_dump(Key::Up === Key::Up); -var_dump(Key::Up !== Key::Down); - -?> ---EXPECT-- -bool(true) -Total cases: 26 -bool(true) -bool(true) -bool(true) diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt index 834b55c8a816..548f3e096ec0 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -17,26 +17,20 @@ $res2 = $t->readKey(Duration::fromMilliseconds(1)); var_dump($res2 === false || is_string($res2) || is_object($res2)); // Negative duration for timeout must throw ValueError -$d1 = Duration::fromSeconds(1); -$d2 = Duration::fromSeconds(5); -if (method_exists($d1, 'sub')) { - $neg = $d1->sub($d2); - try { - $t->readKey($neg); - echo "FAIL: accepted negative timeout\n"; - } catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; - } - - try { - $t->readKey(Duration::fromSeconds(0), $neg); - echo "FAIL: accepted negative sequence timeout\n"; - } catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; - } -} else { - echo "ValueError: Io\Terminal\Terminal::readKey(): Argument #1 (\$timeout) must not be negative\n"; - echo "ValueError: Io\Terminal\Terminal::readKey(): Argument #2 (\$sequenceTimeout) must not be negative\n"; +$neg = Duration::fromSeconds(1)->sub(Duration::fromSeconds(5)); + +try { + $t->readKey($neg); + echo "FAIL: accepted negative timeout\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +try { + $t->readKey(Duration::fromSeconds(0), $neg); + echo "FAIL: accepted negative sequence timeout\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } // Non-terminal stream returns false immediately From 99500f26f89ca47d34908b31935a691660d820c3 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 20:27:05 +0545 Subject: [PATCH 15/38] Address follow-up terminal review feedback --- ext/standard/io_poll.c | 2 +- ext/standard/io_poll.h | 1 - ext/standard/io_terminal.c | 28 +++++++++++-------- .../tests/terminal/terminal_create.phpt | 8 +++--- .../tests/terminal/terminal_read_key.phpt | 6 ++-- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/ext/standard/io_poll.c b/ext/standard/io_poll.c index fceb043ad98f..dd0b27ae0067 100644 --- a/ext/standard/io_poll.c +++ b/ext/standard/io_poll.c @@ -28,7 +28,7 @@ static zend_class_entry *php_io_poll_event_class_entry; static zend_class_entry *php_io_poll_context_class_entry; static zend_class_entry *php_io_poll_watcher_class_entry; static zend_class_entry *php_io_poll_handle_class_entry; -PHPAPI zend_class_entry *php_io_exception_class_entry; +static zend_class_entry *php_io_exception_class_entry; static zend_class_entry *php_io_poll_exception_class_entry; static zend_class_entry *php_io_poll_failed_backend_unavailable_class_entry; static zend_class_entry *php_io_poll_failed_operation_class_entry; diff --git a/ext/standard/io_poll.h b/ext/standard/io_poll.h index 00ee5e6aa5ee..a8384001384d 100644 --- a/ext/standard/io_poll.h +++ b/ext/standard/io_poll.h @@ -19,7 +19,6 @@ BEGIN_EXTERN_C() -PHPAPI extern zend_class_entry *php_io_exception_class_entry; PHPAPI void php_io_poll_stream_notify_close(php_stream *stream); diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 4972d980f67e..898d2cdbf670 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -21,7 +21,6 @@ #include "php_streams.h" #include "ext/standard/basic_functions.h" #include "ext/date/php_time.h" -#include "io_poll.h" #include "io_terminal.h" #include "io_terminal_decl.h" #include "io_terminal_arginfo.h" @@ -759,12 +758,6 @@ static zend_string *php_io_terminal_read_stream_secret( { HANDLE handle = input; DWORD mode; - WCHAR high_surrogate = 0; - DWORD raw_mode; - smart_str secret = {0}; - bool success = false; - bool failed = false; - bool mode_changed = false; if (handle == INVALID_HANDLE_VALUE || handle == NULL || (stream != NULL && stream->writepos > stream->readpos) @@ -772,11 +765,16 @@ static zend_string *php_io_terminal_read_stream_secret( return NULL; } - raw_mode = php_io_terminal_make_raw_mode(mode); + DWORD raw_mode = php_io_terminal_make_raw_mode(mode); if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { return NULL; } - mode_changed = raw_mode != mode; + + bool mode_changed = raw_mode != mode; + WCHAR high_surrogate = 0; + smart_str secret = {0}; + bool success = false; + bool failed = false; for (;;) { INPUT_RECORD record; @@ -1110,7 +1108,7 @@ static zend_string *php_io_terminal_key_from_utf8_sequence( size_t sequence_len = php_io_terminal_utf8_sequence_len(key); if (sequence_len == 1) { - return zend_string_init((const char *) &key, 1, false); + return ZSTR_CHAR(key); } memset(pending, 0, sizeof(*pending)); @@ -1801,9 +1799,15 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) PHP_MINIT_FUNCTION(terminal) { - ZEND_ASSERT(php_io_exception_class_entry != NULL); + zend_class_entry *io_exception_ce = zend_hash_str_find_ptr( + CG(class_table), + "io\\ioexception", + sizeof("io\\ioexception") - 1 + ); + ZEND_ASSERT(io_exception_ce != NULL); + php_io_terminal_exception_ce - = register_class_Io_Terminal_TerminalException(php_io_exception_class_entry); + = register_class_Io_Terminal_TerminalException(io_exception_ce); php_io_terminal_key_ce = register_class_Io_Terminal_Key(); diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index 9891da2c79ac..e07c20c50fcf 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -58,15 +58,15 @@ try { } ?> ---EXPECTF-- +--EXPECT-- bool(true) bool(true) bool(true) TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given -Error: %s -Error: %s -Error: %s +Error: Call to private Io\Terminal\Terminal::__construct() from global scope +Error: Call to private Io\Terminal\TerminalSize::__construct() from global scope +Error: Call to private Io\Terminal\ModeToken::__construct() from global scope Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal Exception: Serialization of 'Io\Terminal\Terminal' is not allowed diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt index 548f3e096ec0..87c37977f480 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -17,17 +17,15 @@ $res2 = $t->readKey(Duration::fromMilliseconds(1)); var_dump($res2 === false || is_string($res2) || is_object($res2)); // Negative duration for timeout must throw ValueError -$neg = Duration::fromSeconds(1)->sub(Duration::fromSeconds(5)); - try { - $t->readKey($neg); + $t->readKey(Duration::fromSeconds(1)->negate()); echo "FAIL: accepted negative timeout\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } try { - $t->readKey(Duration::fromSeconds(0), $neg); + $t->readKey(Duration::fromSeconds(0), Duration::fromSeconds(1)->negate()); echo "FAIL: accepted negative sequence timeout\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; From 337395e3c5cd8f7f44c23ef11a0d75696be8fd24 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 21:41:23 +0545 Subject: [PATCH 16/38] Fix terminal reflection test expectation --- .../tests/ReflectionExtension_getClassNames_basic.phpt | 1 + 1 file changed, 1 insertion(+) diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index edb7f9dfb07d..c0b95563215e 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -33,6 +33,7 @@ Io\Poll\Watcher Io\Terminal\Key Io\Terminal\ModeToken Io\Terminal\Terminal +Io\Terminal\TerminalException Io\Terminal\TerminalSize RoundingMode SortDirection From 1f2f1383a9aecce5c2a4bd04fec9a3d09f060400 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sun, 27 Sep 2026 22:23:10 +0545 Subject: [PATCH 17/38] Address terminal review feedback and fix macOS pty test --- ext/standard/io_poll.h | 1 - ext/standard/io_terminal.c | 22 +++++-------------- .../tests/terminal/terminal_create.phpt | 15 ++++++++----- .../tests/terminal/terminal_read_key.phpt | 12 +--------- .../terminal/terminal_read_key_sequences.phpt | 1 + 5 files changed, 16 insertions(+), 35 deletions(-) diff --git a/ext/standard/io_poll.h b/ext/standard/io_poll.h index a8384001384d..1b94355f27a5 100644 --- a/ext/standard/io_poll.h +++ b/ext/standard/io_poll.h @@ -19,7 +19,6 @@ BEGIN_EXTERN_C() - PHPAPI void php_io_poll_stream_notify_close(php_stream *stream); END_EXTERN_C() diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 898d2cdbf670..173bad52d847 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -336,10 +336,7 @@ static void php_io_terminal_mode_token_free_obj(zend_object *object) } php_io_terminal_untrack_mode_token(intern); - if (!Z_ISUNDEF(intern->stream_resource)) { - zval_ptr_dtor(&intern->stream_resource); - ZVAL_UNDEF(&intern->stream_resource); - } + zval_ptr_dtor(&intern->stream_resource); memset(&intern->saved, 0, sizeof(intern->saved)); intern->valid = false; @@ -392,15 +389,8 @@ static void php_io_terminal_free_obj(zend_object *object) intern->active_mode_token = NULL; } - if (!Z_ISUNDEF(intern->input_stream_val)) { - zval_ptr_dtor(&intern->input_stream_val); - ZVAL_UNDEF(&intern->input_stream_val); - } - - if (!Z_ISUNDEF(intern->output_stream_val)) { - zval_ptr_dtor(&intern->output_stream_val); - ZVAL_UNDEF(&intern->output_stream_val); - } + zval_ptr_dtor(&intern->input_stream_val); + zval_ptr_dtor(&intern->output_stream_val); zend_object_std_dtor(&intern->std); } @@ -1607,10 +1597,8 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) php_io_terminal_create_mode_token(return_value, &saved, stream.stream_resource); - if (Z_TYPE_P(return_value) == IS_OBJECT && instanceof_function(Z_OBJCE_P(return_value), php_io_terminal_mode_token_ce)) { - intern->active_mode_token = Z_OBJ_P(return_value); - GC_ADDREF(intern->active_mode_token); - } + intern->active_mode_token = Z_OBJ_P(return_value); + GC_ADDREF(intern->active_mode_token); } PHP_METHOD(Io_Terminal_Terminal, restoreMode) diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index e07c20c50fcf..40b53e5b1499 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -9,15 +9,15 @@ use Io\Terminal\ModeToken; // Named constructors $t1 = Terminal::create(); -var_dump($t1 instanceof Terminal); +var_dump($t1); $fp = fopen('php://temp', 'r+'); $t2 = Terminal::fromStreams($fp); -var_dump($t2 instanceof Terminal); +var_dump($t2); $fpOut = fopen('php://temp', 'r+'); $t3 = Terminal::fromStreams($fp, $fpOut); -var_dump($t3 instanceof Terminal); +var_dump($t3); // Invalid stream arguments try { @@ -59,9 +59,12 @@ try { ?> --EXPECT-- -bool(true) -bool(true) -bool(true) +object(Io\Terminal\Terminal)#1 (0) { +} +object(Io\Terminal\Terminal)#2 (0) { +} +object(Io\Terminal\Terminal)#3 (0) { +} TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given Error: Call to private Io\Terminal\Terminal::__construct() from global scope diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt index 87c37977f480..90294db06a8d 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -8,14 +8,6 @@ use Time\Duration; $t = Terminal::create(); -// Zero duration poll -$res = $t->readKey(Duration::fromSeconds(0)); -var_dump($res === false || is_string($res) || is_object($res)); - -// Positive duration -$res2 = $t->readKey(Duration::fromMilliseconds(1)); -var_dump($res2 === false || is_string($res2) || is_object($res2)); - // Negative duration for timeout must throw ValueError try { $t->readKey(Duration::fromSeconds(1)->negate()); @@ -37,9 +29,7 @@ $nonTty = Terminal::fromStreams($fp); var_dump($nonTty->readKey(Duration::fromSeconds(0))); ?> ---EXPECTF-- -bool(true) -bool(true) +--EXPECT-- ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt index f57cc268c41a..bae544899da7 100644 --- a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt @@ -56,6 +56,7 @@ $proc = proc_open( ); $terminal = Terminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); $timeout = Duration::fromSeconds(1); foreach (range(1, 4) as $_) { From 3158d03ff0c9fdc1402d222499b5697ef9243a11 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 15:46:27 +0545 Subject: [PATCH 18/38] Harden Io\Terminal mode ownership, resize handling, and input validation - Coordinate POSIX raw mode token ownership by underlying terminal device identity (tty_dev) so overlapping sessions and multiple descriptors reliably restore canonical mode. - Forward SIGWINCH to existing signal handlers and track terminal dimensions on Terminal instances to detect between-read resizes. - Allow zero-duration readKey() to return immediately without waiting for sequence timeout. Clamp finite timeouts and guard time_t conversions. - Validate continuation bytes during UTF-8 secret reading and dispatch non-continuation control bytes to preserve Ctrl+C cancellation. - Preserve high surrogate across repeated Windows console input events. - Replace synthetic non-TTY test fallbacks with genuine PTY tests and add regression tests for non-TTY streams, timeouts, secret controls, and resizes. --- ext/standard/io_terminal.c | 182 ++++++++++++++++-- .../terminal_overlapping_raw_mode.phpt | 79 ++++++-- .../tests/terminal/terminal_raw_mode.phpt | 113 +++++++---- .../terminal/terminal_raw_mode_non_tty.phpt | 18 ++ .../terminal/terminal_read_key_timeout.phpt | 75 ++++++++ .../terminal_read_secret_control.phpt | 66 +++++++ .../tests/terminal/terminal_resize.phpt | 88 +++++++++ 7 files changed, 560 insertions(+), 61 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_key_timeout.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_secret_control.phpt create mode 100644 ext/standard/tests/terminal/terminal_resize.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 173bad52d847..9886069d504b 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -46,6 +46,11 @@ #define PHP_IO_TERMINAL_MODE_TOKEN_MAGIC "PHPTTY1" #define PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN (sizeof(PHP_IO_TERMINAL_MODE_TOKEN_MAGIC) - 1) #define PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS 25 +#if SIZEOF_TIME_T < 8 +# define PHP_IO_TERMINAL_TIME_T_MAX INT32_MAX +#else +# define PHP_IO_TERMINAL_TIME_T_MAX INT64_MAX +#endif #ifdef PHP_WIN32 typedef HANDLE php_io_terminal_native_stream; @@ -66,6 +71,8 @@ typedef struct php_io_terminal_saved_mode { DWORD mode; #else struct termios mode; + dev_t tty_dev; + ino_t tty_ino; #endif } php_io_terminal_saved_mode; @@ -83,6 +90,9 @@ typedef struct php_io_terminal_object { zval input_stream_val; zval output_stream_val; zend_object *active_mode_token; + zend_long last_cols; + zend_long last_rows; + bool has_last_size; #ifdef PHP_WIN32 INPUT_RECORD pending_key; WCHAR pending_key_high_surrogate; @@ -138,13 +148,28 @@ static void php_io_terminal_resize_unlock(void) #endif } -static void php_io_terminal_sigwinch_handler(int signo) +static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) { (void) signo; php_io_terminal_resize_generation = php_io_terminal_resize_generation == SIG_ATOMIC_MAX ? 0 : php_io_terminal_resize_generation + 1; + + /* Forward to existing handler if present and not default/ignored */ + if (php_io_terminal_previous_resize_action.sa_flags & SA_SIGINFO) { + if (php_io_terminal_previous_resize_action.sa_sigaction != NULL + && php_io_terminal_previous_resize_action.sa_sigaction != (void *) SIG_DFL + && php_io_terminal_previous_resize_action.sa_sigaction != (void *) SIG_IGN) { + php_io_terminal_previous_resize_action.sa_sigaction(signo, info, context); + } + } else { + if (php_io_terminal_previous_resize_action.sa_handler != NULL + && php_io_terminal_previous_resize_action.sa_handler != SIG_DFL + && php_io_terminal_previous_resize_action.sa_handler != SIG_IGN) { + php_io_terminal_previous_resize_action.sa_handler(signo); + } + } } static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation) @@ -158,7 +183,8 @@ static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation) if (php_io_terminal_resize_readers == 0) { memset(&action, 0, sizeof(action)); - action.sa_handler = php_io_terminal_sigwinch_handler; + action.sa_sigaction = php_io_terminal_sigwinch_handler; + action.sa_flags = SA_SIGINFO; sigemptyset(&action.sa_mask); installed = sigaction(SIGWINCH, &action, &php_io_terminal_previous_resize_action) == 0; @@ -208,13 +234,46 @@ static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream #endif } -static bool php_io_terminal_mode_streams_match(php_io_terminal_native_stream first, php_io_terminal_native_stream second) +static bool php_io_terminal_mode_streams_match(const php_io_terminal_saved_mode *first, const php_io_terminal_saved_mode *second) { - return first == second; +#ifdef PHP_WIN32 + if (first->stream == second->stream) { + return true; + } + DWORD m1, m2; + return GetConsoleMode(first->stream, &m1) && GetConsoleMode(second->stream, &m2); +#else + if (first->stream == second->stream) { + return true; + } + if (first->tty_dev != 0 && second->tty_dev != 0 && first->tty_dev == second->tty_dev) { + return true; + } + return false; +#endif } static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mode_token_object *mode) { + if (!php_io_terminal_native_stream_is_valid(mode->saved.stream)) { + return false; + } + +#ifdef PHP_WIN32 + DWORD m; + if (!GetConsoleMode(mode->saved.stream, &m)) { + return false; + } +#else + struct stat st; + if (fstat(mode->saved.stream, &st) != 0 || !S_ISCHR(st.st_mode)) { + return false; + } + if (mode->saved.tty_dev != 0 && st.st_rdev != mode->saved.tty_dev) { + return false; + } +#endif + if (!Z_ISUNDEF(mode->stream_resource)) { php_stream *stream; php_io_terminal_native_stream native_stream; @@ -234,13 +293,28 @@ static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mod } native_stream = php_io_terminal_native_stream_from_php_stream(stream); - if (!php_io_terminal_native_stream_is_valid(native_stream) - || !php_io_terminal_mode_streams_match(native_stream, mode->saved.stream)) { + if (!php_io_terminal_native_stream_is_valid(native_stream)) { return false; } +#ifdef PHP_WIN32 + if (native_stream != mode->saved.stream) { + DWORD m1, m2; + if (!GetConsoleMode(native_stream, &m1) || !GetConsoleMode(mode->saved.stream, &m2)) { + return false; + } + } +#else + if (native_stream != mode->saved.stream) { + struct stat st_cur; + if (fstat(native_stream, &st_cur) != 0 || !S_ISCHR(st_cur.st_mode) + || mode->saved.tty_dev == 0 || st_cur.st_rdev != mode->saved.tty_dev) { + return false; + } + } +#endif } - return php_io_terminal_native_stream_is_valid(mode->saved.stream); + return true; } static bool php_io_terminal_restore_stream_mode(const php_io_terminal_saved_mode *saved) @@ -295,7 +369,7 @@ static bool php_io_terminal_release_mode_token(php_io_terminal_mode_token_object bool restored; while (candidate != NULL && candidate != mode) { - if (candidate->valid && php_io_terminal_mode_streams_match(candidate->saved.stream, mode->saved.stream)) { + if (candidate->valid && php_io_terminal_mode_streams_match(&candidate->saved, &mode->saved)) { newer = candidate; } candidate = candidate->active_next; @@ -402,6 +476,9 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) ZVAL_UNDEF(&intern->input_stream_val); ZVAL_UNDEF(&intern->output_stream_val); intern->active_mode_token = NULL; + intern->last_cols = 0; + intern->last_rows = 0; + intern->has_last_size = false; #ifdef PHP_WIN32 memset(&intern->pending_key, 0, sizeof(intern->pending_key)); intern->pending_key_high_surrogate = 0; @@ -715,12 +792,13 @@ static zend_string *php_io_terminal_read_stream_key( continue; } + WCHAR prev_high = high_surrogate; result = php_io_terminal_key_from_input_record(key, &high_surrogate); if (result != NULL) { if (key->wRepeatCount > 1) { key->wRepeatCount--; *pending_key = record; - *pending_key_high_surrogate = high_surrogate; + *pending_key_high_surrogate = prev_high; } break; } @@ -859,14 +937,21 @@ static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream { struct termios mode; struct termios raw_mode; + struct stat st; if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { return false; } + if (fstat(fd, &st) != 0 || !S_ISCHR(st.st_mode)) { + return false; + } + memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); saved->stream = fd; saved->mode = mode; + saved->tty_dev = st.st_rdev; + saved->tty_ino = st.st_ino; raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); @@ -1331,6 +1416,7 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st break; } +process_key: switch (key) { case '\r': case '\n': @@ -1361,13 +1447,27 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st } else { unsigned char seq[4]; size_t i; + bool invalid_continuation = false; + unsigned char invalid_byte = 0; + seq[0] = key; for (i = 1; i < seq_len; i++) { if (php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], &sequence_timeout, false, NULL) != 1) { break; } + if ((seq[i] & 0xc0) != 0x80) { + invalid_continuation = true; + invalid_byte = seq[i]; + break; + } + } + if (invalid_continuation) { + key = invalid_byte; + goto process_key; + } + if (i == seq_len) { + smart_str_appendl(&secret, (const char *) seq, i); } - smart_str_appendl(&secret, (const char *) seq, i); } break; } @@ -1565,6 +1665,10 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) RETURN_FALSE; } + intern->last_cols = columns; + intern->last_rows = rows; + intern->has_last_size = true; + php_io_terminal_create_terminal_size(return_value, columns, rows); } @@ -1659,6 +1763,7 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) php_date_time_duration *seq_timeout_duration = NULL; php_io_terminal_object *intern; php_io_terminal_stream_target stream; + php_io_terminal_stream_target out_stream; zend_string *key; zend_object *key_case; @@ -1680,22 +1785,37 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + if (php_io_terminal_stream_target_init(&intern->output_stream_val, false, &out_stream)) { + zend_long cur_cols = 0, cur_rows = 0; + if (php_io_terminal_stream_size(out_stream.native_stream, &cur_cols, &cur_rows)) { + if (intern->has_last_size && (cur_cols != intern->last_cols || cur_rows != intern->last_rows)) { + intern->last_cols = cur_cols; + intern->last_rows = cur_rows; + RETURN_OBJ_COPY(zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize")); + } + intern->last_cols = cur_cols; + intern->last_rows = cur_rows; + intern->has_last_size = true; + } + } + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); } #ifdef PHP_WIN32 +# define PHP_IO_TERMINAL_MAX_WAIT_MS (INFINITE - 1) DWORD wait_ms = INFINITE; if (timeout_duration != NULL) { uint64_t sec = timeout_duration->duration.seconds; uint32_t nsec = timeout_duration->duration.nanoseconds; if (sec == 0 && nsec == 0) { wait_ms = 0; - } else if (sec >= (DWORD) INFINITE / 1000) { - wait_ms = INFINITE; + } else if (sec >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS / 1000) { + wait_ms = PHP_IO_TERMINAL_MAX_WAIT_MS; } else { uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; - wait_ms = ms >= (DWORD) INFINITE ? INFINITE : (DWORD) ms; + wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; } } @@ -1711,16 +1831,37 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) struct timespec timeout; struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; const struct timespec *timeout_ptr = NULL; + bool is_non_blocking = false; if (timeout_duration != NULL) { - timeout.tv_sec = timeout_duration->duration.seconds; + is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); +#if SIZEOF_TIME_T < 8 + if (timeout_duration->duration.seconds > PHP_IO_TERMINAL_TIME_T_MAX) { + timeout.tv_sec = PHP_IO_TERMINAL_TIME_T_MAX; + } else { + timeout.tv_sec = (time_t) timeout_duration->duration.seconds; + } +#else + timeout.tv_sec = (time_t) timeout_duration->duration.seconds; +#endif timeout.tv_nsec = timeout_duration->duration.nanoseconds; timeout_ptr = &timeout; } if (seq_timeout_duration != NULL) { - sequence_timeout.tv_sec = seq_timeout_duration->duration.seconds; +#if SIZEOF_TIME_T < 8 + if (seq_timeout_duration->duration.seconds > PHP_IO_TERMINAL_TIME_T_MAX) { + sequence_timeout.tv_sec = PHP_IO_TERMINAL_TIME_T_MAX; + } else { + sequence_timeout.tv_sec = (time_t) seq_timeout_duration->duration.seconds; + } +#else + sequence_timeout.tv_sec = (time_t) seq_timeout_duration->duration.seconds; +#endif sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; + } else if (is_non_blocking) { + sequence_timeout.tv_sec = 0; + sequence_timeout.tv_nsec = 0; } key = php_io_terminal_read_stream_key( @@ -1738,6 +1879,17 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) key_case = php_io_terminal_key_enum_from_string(key); if (key_case != NULL) { + if (key_case == zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize")) { + php_io_terminal_stream_target out_s; + if (php_io_terminal_stream_target_init(&intern->output_stream_val, false, &out_s)) { + zend_long c = 0, r = 0; + if (php_io_terminal_stream_size(out_s.native_stream, &c, &r)) { + intern->last_cols = c; + intern->last_rows = r; + intern->has_last_size = true; + } + } + } zend_string_release(key); RETURN_OBJ_COPY(key_case); } diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt index 97050c4800cd..4d013f77591d 100644 --- a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -1,30 +1,85 @@ --TEST-- Io\Terminal\Terminal: overlapping raw-mode sessions and out-of-order restoration +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> --FILE-- ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +// Two separate Terminal instances referencing descriptors on the same PTY +$t1 = Terminal::fromStreams($pipes[0]); +$t2 = Terminal::fromStreams($pipes[1]); +// Test 1: Out-of-order restore (m1 then m2) $m1 = $t1->enableRawMode(); $m2 = $t2->enableRawMode(); -if ($m1 instanceof ModeToken && $m2 instanceof ModeToken) { - // Both active: restore m1 first (out of order) - var_dump($t1->restoreMode($m1)); +var_dump($m1 instanceof ModeToken); +var_dump($m2 instanceof ModeToken); - // Now restore m2 - var_dump($t2->restoreMode($m2)); -} else { - // Fallback in non-tty runner - var_dump(true); - var_dump(true); -} +// Out-of-order: restore older token m1 first +var_dump($t1->restoreMode($m1)); + +// Now restore m2 +var_dump($t2->restoreMode($m2)); +// Test 2: In-order restore (m2 then m1) +$m1 = $t1->enableRawMode(); +$m2 = $t2->enableRawMode(); + +var_dump($t2->restoreMode($m2)); +var_dump($t1->restoreMode($m1)); + +fwrite($pipes[0], "exit\n"); +unset($t1, $t2); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); ?> --EXPECT-- bool(true) bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt index b405c3415454..ef10b9a1e7c0 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -1,55 +1,100 @@ --TEST-- Io\Terminal\Terminal: enableRawMode, restoreMode, session mode tracking and stale token protection +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> --FILE-- ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); -// Non-terminal stream should return false for enableRawMode -$fp = fopen('php://temp', 'r+'); -$nonTty = Terminal::fromStreams($fp); -var_dump($nonTty->enableRawMode()); +$terminal = Terminal::fromStreams($pipes[0]); -// Explicit token restore on valid terminal (if available in test runner) +// Explicit token restore on valid terminal $token = $terminal->enableRawMode(); -if ($token instanceof ModeToken) { - echo "enableRawMode: ModeToken\n"; - - // Restore explicitly with token - var_dump($terminal->restoreMode($token)); - - // Stale consumed token must throw ValueError - try { - $terminal->restoreMode($token); - echo "FAIL: stale token was accepted\n"; - } catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; - } - - // Session-managed restore without args - $token2 = $terminal->enableRawMode(); - var_dump($token2 instanceof ModeToken); - var_dump($terminal->restoreMode()); // Restores active token - var_dump($terminal->restoreMode()); // Already restored -> returns false -} else { - // Non-interactive runner fallback check - echo "enableRawMode: ModeToken\n"; - echo "bool(true)\n"; - echo "ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 (\$mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode()\n"; - echo "bool(true)\n"; - echo "bool(true)\n"; - echo "bool(false)\n"; +echo "enableRawMode: ", ($token instanceof ModeToken ? "ModeToken" : "false"), "\n"; + +// Restore explicitly with token +var_dump($terminal->restoreMode($token)); + +// Stale consumed token must throw ValueError +try { + $terminal->restoreMode($token); + echo "FAIL: stale token was accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; } +// Session-managed restore without args +$token2 = $terminal->enableRawMode(); +var_dump($token2 instanceof ModeToken); +var_dump($terminal->restoreMode()); // Restores active token +var_dump($terminal->restoreMode()); // Already restored -> returns false + +// Auto-restoration on Terminal object destruction +$terminal2 = Terminal::fromStreams($pipes[0]); +$token3 = $terminal2->enableRawMode(); +var_dump($token3 instanceof ModeToken); +unset($terminal2); // Terminal destructor releases active mode token + +// Restoring with the token from the destroyed terminal must throw ValueError +try { + $terminal->restoreMode($token3); + echo "FAIL: token from destroyed terminal was accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes[0], "exit\n"); +unset($terminal, $token3); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); ?> --EXPECTF-- -bool(false) enableRawMode: ModeToken bool(true) ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() bool(true) bool(true) bool(false) +bool(true) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() diff --git a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt new file mode 100644 index 000000000000..09c1397e6844 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -0,0 +1,18 @@ +--TEST-- +Io\Terminal\Terminal: enableRawMode returns false for non-terminal streams +--FILE-- +enableRawMode()); +var_dump($terminal->restoreMode()); + +fclose($fp); +?> +--EXPECT-- +bool(false) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_key_timeout.phpt b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt new file mode 100644 index 000000000000..b2545696624c --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt @@ -0,0 +1,75 @@ +--TEST-- +Io\Terminal\Terminal: readKey zero timeout returns Escape immediately without sequence delay +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = Terminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); + +// Signal child to write lone escape +fwrite($pipes[0], "GO\n"); +$ready = fgets($pipes[2]); + +// Read with zero timeout (non-blocking) +$start = hrtime(true); +$key = $terminal->readKey(Duration::fromSeconds(0)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($key === Key::Escape); +// Verify it did not block for 25ms sequence timeout (should be < 15ms) +var_dump($elapsed_ms < 15.0); + +fwrite($pipes[0], "DONE\n"); +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_read_secret_control.phpt b/ext/standard/tests/terminal/terminal_read_secret_control.phpt new file mode 100644 index 000000000000..b2bce0ca8de8 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt @@ -0,0 +1,66 @@ +--TEST-- +Io\Terminal\Terminal: readSecret cancels and throws on malformed UTF-8 with control characters +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = Terminal::fromStreams($pipes[0]); + +try { + $secret = $terminal->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt new file mode 100644 index 000000000000..f0fb31f81cb8 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_resize.phpt @@ -0,0 +1,88 @@ +--TEST-- +Io\Terminal\Terminal: readKey detects resize and updates terminal size +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$r1 = fgets($pipes[2]); +$terminal = Terminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); + +$s1 = $terminal->getSize(); +echo "s1: {$s1->cols}x{$s1->rows}\n"; + +// Signal child to change window size +fwrite($pipes[0], "next\n"); +$r2 = fgets($pipes[2]); + +// readKey should detect size change and return Key::Resize +$key = $terminal->readKey(Duration::fromSeconds(0)); +var_dump($key === Key::Resize); + +$s2 = $terminal->getSize(); +echo "s2: {$s2->cols}x{$s2->rows}\n"; + +fwrite($pipes[0], "done\n"); +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +s1: 100x30 +bool(true) +s2: 120x35 From e612f66cda7cd8b054c803217a6a6e126e84cdca Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 16:35:43 +0545 Subject: [PATCH 19/38] Fix SIGWINCH handler sentinel checks --- ext/standard/io_terminal.c | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 9886069d504b..9ee8d3871059 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -148,6 +148,34 @@ static void php_io_terminal_resize_unlock(void) #endif } +static zend_always_inline bool php_io_terminal_is_callable_sigaction(void (*handler)(int, siginfo_t *, void *)) +{ + if (handler == NULL) { + return false; + } + if (handler == (void (*)(int, siginfo_t *, void *)) SIG_DFL) { + return false; + } + if (handler == (void (*)(int, siginfo_t *, void *)) SIG_IGN) { + return false; + } + return true; +} + +static zend_always_inline bool php_io_terminal_is_callable_handler(void (*handler)(int)) +{ + if (handler == NULL) { + return false; + } + if (handler == SIG_DFL) { + return false; + } + if (handler == SIG_IGN) { + return false; + } + return true; +} + static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) { (void) signo; @@ -158,15 +186,11 @@ static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *c /* Forward to existing handler if present and not default/ignored */ if (php_io_terminal_previous_resize_action.sa_flags & SA_SIGINFO) { - if (php_io_terminal_previous_resize_action.sa_sigaction != NULL - && php_io_terminal_previous_resize_action.sa_sigaction != (void *) SIG_DFL - && php_io_terminal_previous_resize_action.sa_sigaction != (void *) SIG_IGN) { + if (php_io_terminal_is_callable_sigaction(php_io_terminal_previous_resize_action.sa_sigaction)) { php_io_terminal_previous_resize_action.sa_sigaction(signo, info, context); } } else { - if (php_io_terminal_previous_resize_action.sa_handler != NULL - && php_io_terminal_previous_resize_action.sa_handler != SIG_DFL - && php_io_terminal_previous_resize_action.sa_handler != SIG_IGN) { + if (php_io_terminal_is_callable_handler(php_io_terminal_previous_resize_action.sa_handler)) { php_io_terminal_previous_resize_action.sa_handler(signo); } } From 9eaaca4eedec67545af2e0e66085800e9507e067 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 16:51:17 +0545 Subject: [PATCH 20/38] Fix SIGWINCH handler sentinel checks --- ext/standard/io_terminal.c | 39 +++++--------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 9ee8d3871059..a3d603822b4a 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -148,34 +148,6 @@ static void php_io_terminal_resize_unlock(void) #endif } -static zend_always_inline bool php_io_terminal_is_callable_sigaction(void (*handler)(int, siginfo_t *, void *)) -{ - if (handler == NULL) { - return false; - } - if (handler == (void (*)(int, siginfo_t *, void *)) SIG_DFL) { - return false; - } - if (handler == (void (*)(int, siginfo_t *, void *)) SIG_IGN) { - return false; - } - return true; -} - -static zend_always_inline bool php_io_terminal_is_callable_handler(void (*handler)(int)) -{ - if (handler == NULL) { - return false; - } - if (handler == SIG_DFL) { - return false; - } - if (handler == SIG_IGN) { - return false; - } - return true; -} - static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) { (void) signo; @@ -185,13 +157,12 @@ static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *c : php_io_terminal_resize_generation + 1; /* Forward to existing handler if present and not default/ignored */ - if (php_io_terminal_previous_resize_action.sa_flags & SA_SIGINFO) { - if (php_io_terminal_is_callable_sigaction(php_io_terminal_previous_resize_action.sa_sigaction)) { + void (*handler)(int) = php_io_terminal_previous_resize_action.sa_handler; + if (handler != NULL && handler != SIG_DFL && handler != SIG_IGN) { + if (php_io_terminal_previous_resize_action.sa_flags & SA_SIGINFO) { php_io_terminal_previous_resize_action.sa_sigaction(signo, info, context); - } - } else { - if (php_io_terminal_is_callable_handler(php_io_terminal_previous_resize_action.sa_handler)) { - php_io_terminal_previous_resize_action.sa_handler(signo); + } else { + handler(signo); } } } From 031b7972395520d7b32974da0ae5c98896c28007 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 17:03:41 +0545 Subject: [PATCH 21/38] Fix SIGWINCH handler sentinel checks --- ext/standard/io_terminal.c | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index a3d603822b4a..519a89e379c6 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -148,6 +148,20 @@ static void php_io_terminal_resize_unlock(void) #endif } +static zend_always_inline bool php_io_terminal_is_callable_handler(void (*handler)(int)) +{ + if (handler == NULL) { + return false; + } + if (handler == SIG_DFL) { + return false; + } + if (handler == SIG_IGN) { + return false; + } + return true; +} + static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) { (void) signo; @@ -157,12 +171,11 @@ static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *c : php_io_terminal_resize_generation + 1; /* Forward to existing handler if present and not default/ignored */ - void (*handler)(int) = php_io_terminal_previous_resize_action.sa_handler; - if (handler != NULL && handler != SIG_DFL && handler != SIG_IGN) { + if (php_io_terminal_is_callable_handler(php_io_terminal_previous_resize_action.sa_handler)) { if (php_io_terminal_previous_resize_action.sa_flags & SA_SIGINFO) { php_io_terminal_previous_resize_action.sa_sigaction(signo, info, context); } else { - handler(signo); + php_io_terminal_previous_resize_action.sa_handler(signo); } } } From 7980ef82bb19f976c52f444aac2ee16c81c801b7 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 21:25:45 +0545 Subject: [PATCH 22/38] Address Io\Terminal discussion feedback --- ext/standard/io_terminal.c | 403 +++++++++++++++--- ext/standard/io_terminal.stub.php | 6 +- ext/standard/io_terminal_arginfo.h | 21 +- ext/standard/io_terminal_decl.h | 8 +- .../terminal/terminal_controlling_tty.phpt | 49 +++ .../tests/terminal/terminal_create.phpt | 42 +- .../tests/terminal/terminal_identity.phpt | 94 ++++ .../terminal_overlapping_raw_mode.phpt | 60 ++- .../tests/terminal/terminal_raw_mode.phpt | 53 ++- .../terminal/terminal_raw_mode_non_tty.phpt | 11 +- .../tests/terminal/terminal_read_key.phpt | 13 +- .../terminal/terminal_read_key_contracts.phpt | 118 +++++ 12 files changed, 786 insertions(+), 92 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_controlling_tty.phpt create mode 100644 ext/standard/tests/terminal/terminal_identity.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_key_contracts.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 519a89e379c6..73c00c9bb4fc 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -35,6 +35,7 @@ # include # include #else +# include # include # include # include @@ -64,6 +65,20 @@ typedef struct php_io_terminal_utf8_pending { } php_io_terminal_utf8_pending; #endif +#ifdef PHP_WIN32 +typedef struct php_io_terminal_identity { + php_io_terminal_native_stream stream; +} php_io_terminal_identity; +#else +typedef struct php_io_terminal_identity { + php_io_terminal_native_stream stream; + dev_t dev; + bool has_dev; + pid_t sid; + bool has_sid; +} php_io_terminal_identity; +#endif + typedef struct php_io_terminal_saved_mode { char magic[PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN]; php_io_terminal_native_stream stream; @@ -71,9 +86,8 @@ typedef struct php_io_terminal_saved_mode { DWORD mode; #else struct termios mode; - dev_t tty_dev; - ino_t tty_ino; #endif + php_io_terminal_identity identity; } php_io_terminal_saved_mode; typedef struct php_io_terminal_mode_token_object { @@ -120,7 +134,18 @@ static zend_object_handlers php_io_terminal_object_handlers; ZEND_TLS php_io_terminal_mode_token_object *php_io_terminal_active_mode_tokens; #if !defined(PHP_WIN32) -#define PHP_IO_TERMINAL_READ_RESIZE 2 +# if defined(HAVE_PTSNAME_R) || defined(_GNU_SOURCE) || defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__) +# define PHP_IO_TERMINAL_HAVE_PTSNAME_R 1 +# endif +# if !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) +static MUTEX_T php_io_terminal_ptsname_mutex = NULL; +# endif + +#define PHP_IO_TERMINAL_READ_EOF (-2) +#define PHP_IO_TERMINAL_READ_ERROR (-1) +#define PHP_IO_TERMINAL_READ_TIMEOUT 0 +#define PHP_IO_TERMINAL_READ_SUCCESS 1 +#define PHP_IO_TERMINAL_READ_RESIZE 2 #ifdef SIGWINCH static volatile sig_atomic_t php_io_terminal_resize_generation = 0; @@ -242,49 +267,161 @@ static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream #endif } -static bool php_io_terminal_mode_streams_match(const php_io_terminal_saved_mode *first, const php_io_terminal_saved_mode *second) -{ #ifdef PHP_WIN32 +static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity) +{ + identity->stream = handle; + if (!php_io_terminal_native_stream_is_valid(handle)) { + return false; + } + DWORD mode; + return GetConsoleMode(handle, &mode); +} + +static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) +{ if (first->stream == second->stream) { return true; } DWORD m1, m2; return GetConsoleMode(first->stream, &m1) && GetConsoleMode(second->stream, &m2); +} +#else +#ifndef PATH_MAX +# define PATH_MAX 1024 +#endif + +static bool php_io_terminal_get_slave_dev_from_pty_master(int fd, dev_t *slave_dev) +{ + char slave_path[PATH_MAX]; + bool got_slave_name = false; + +#if defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) + if (ptsname_r(fd, slave_path, sizeof(slave_path)) == 0) { + got_slave_name = true; + } #else +# if defined(ZTS) + if (php_io_terminal_ptsname_mutex != NULL) { + tsrm_mutex_lock(php_io_terminal_ptsname_mutex); + } +# endif + const char *pts = ptsname(fd); + if (pts != NULL) { + size_t len = strlen(pts); + if (len < sizeof(slave_path)) { + memcpy(slave_path, pts, len + 1); + got_slave_name = true; + } + } +# if defined(ZTS) + if (php_io_terminal_ptsname_mutex != NULL) { + tsrm_mutex_unlock(php_io_terminal_ptsname_mutex); + } +# endif +#endif + + if (got_slave_name && slave_path[0] != '\0') { + struct stat st; + if (stat(slave_path, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) { + *slave_dev = st.st_rdev; + return true; + } + } + + return false; +} + +static bool php_io_terminal_get_identity(php_io_terminal_native_stream fd, php_io_terminal_identity *identity) +{ + memset(identity, 0, sizeof(*identity)); + identity->stream = fd; + identity->sid = -1; + + if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1) { + return false; + } + + pid_t sid = tcgetsid(fd); + if (sid > 0) { + identity->sid = sid; + identity->has_sid = true; + } + + dev_t slave_dev = 0; + if (php_io_terminal_get_slave_dev_from_pty_master(fd, &slave_dev)) { + identity->dev = slave_dev; + identity->has_dev = true; + return true; + } + +#if defined(TIOCGDEV) + unsigned int kdev = 0; + if (ioctl(fd, TIOCGDEV, &kdev) == 0 && kdev != 0) { + identity->dev = (dev_t) kdev; + identity->has_dev = true; + return true; + } +#endif + + struct stat st; + if (fstat(fd, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) { + struct stat st_tty; + if (stat("/dev/tty", &st_tty) == 0 && S_ISCHR(st_tty.st_mode) && st.st_rdev == st_tty.st_rdev) { + /* /dev/tty is an indirect alias identified by its controlling terminal session */ + } else { + identity->dev = st.st_rdev; + identity->has_dev = true; + return true; + } + } + + return identity->has_dev || identity->has_sid; +} + +static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) +{ if (first->stream == second->stream) { return true; } - if (first->tty_dev != 0 && second->tty_dev != 0 && first->tty_dev == second->tty_dev) { + + if (first->has_dev && second->has_dev) { + return first->dev == second->dev; + } + + if (first->has_sid && second->has_sid && first->sid == second->sid) { return true; } + return false; +} #endif + +static bool php_io_terminal_mode_streams_match(const php_io_terminal_saved_mode *first, const php_io_terminal_saved_mode *second) +{ + return php_io_terminal_identities_match(&first->identity, &second->identity); } static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mode_token_object *mode) { + php_io_terminal_identity current_identity; + if (!php_io_terminal_native_stream_is_valid(mode->saved.stream)) { return false; } -#ifdef PHP_WIN32 - DWORD m; - if (!GetConsoleMode(mode->saved.stream, &m)) { - return false; - } -#else - struct stat st; - if (fstat(mode->saved.stream, &st) != 0 || !S_ISCHR(st.st_mode)) { + if (!php_io_terminal_get_identity(mode->saved.stream, ¤t_identity)) { return false; } - if (mode->saved.tty_dev != 0 && st.st_rdev != mode->saved.tty_dev) { + + if (!php_io_terminal_identities_match(&mode->saved.identity, ¤t_identity)) { return false; } -#endif if (!Z_ISUNDEF(mode->stream_resource)) { php_stream *stream; php_io_terminal_native_stream native_stream; + php_io_terminal_identity res_identity; if (Z_TYPE(mode->stream_resource) != IS_RESOURCE) { return false; @@ -304,22 +441,14 @@ static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mod if (!php_io_terminal_native_stream_is_valid(native_stream)) { return false; } -#ifdef PHP_WIN32 - if (native_stream != mode->saved.stream) { - DWORD m1, m2; - if (!GetConsoleMode(native_stream, &m1) || !GetConsoleMode(mode->saved.stream, &m2)) { - return false; - } + + if (!php_io_terminal_get_identity(native_stream, &res_identity)) { + return false; } -#else - if (native_stream != mode->saved.stream) { - struct stat st_cur; - if (fstat(native_stream, &st_cur) != 0 || !S_ISCHR(st_cur.st_mode) - || mode->saved.tty_dev == 0 || st_cur.st_rdev != mode->saved.tty_dev) { - return false; - } + + if (!php_io_terminal_identities_match(&mode->saved.identity, &res_identity)) { + return false; } -#endif } return true; @@ -571,6 +700,48 @@ static bool php_io_terminal_stream_target_init( return false; } +typedef enum php_io_terminal_match_result { + PHP_IO_TERMINAL_MATCH_OK, + PHP_IO_TERMINAL_MATCH_MISMATCH, + PHP_IO_TERMINAL_MATCH_ERROR, +} php_io_terminal_match_result; + +static php_io_terminal_match_result php_io_terminal_terminal_matches_saved_mode( + php_io_terminal_object *intern, + const php_io_terminal_saved_mode *saved +) +{ + php_io_terminal_stream_target stream; + php_io_terminal_identity current_identity; + + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Cannot restore terminal mode: terminal stream is closed or invalid", 0); + } + return PHP_IO_TERMINAL_MATCH_ERROR; + } + + if (!php_io_terminal_native_stream_is_valid(stream.native_stream)) { + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Cannot restore terminal mode: terminal stream is invalid", 0); + } + return PHP_IO_TERMINAL_MATCH_ERROR; + } + + if (!php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) { + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to inspect terminal device", 0); + } + return PHP_IO_TERMINAL_MATCH_ERROR; + } + + if (php_io_terminal_identities_match(¤t_identity, &saved->identity)) { + return PHP_IO_TERMINAL_MATCH_OK; + } + + return PHP_IO_TERMINAL_MATCH_MISMATCH; +} + #ifdef PHP_WIN32 static DWORD php_io_terminal_make_raw_mode(DWORD mode) { @@ -586,6 +757,10 @@ static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream return false; } + if (!php_io_terminal_get_identity(handle, &saved->identity)) { + return false; + } + memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); saved->stream = handle; saved->mode = mode; @@ -734,8 +909,21 @@ static bool php_io_terminal_read_console_record( return true; } - return WaitForSingleObject(handle, wait_ms) == WAIT_OBJECT_0 - && ReadConsoleInputW(handle, record, 1, &records_read) && records_read == 1; + DWORD wait_res = WaitForSingleObject(handle, wait_ms); + if (wait_res == WAIT_TIMEOUT) { + return false; + } + if (wait_res != WAIT_OBJECT_0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to wait on console input", 0); + return false; + } + + if (!ReadConsoleInputW(handle, record, 1, &records_read) || records_read != 1) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read console input", 0); + return false; + } + + return true; } static zend_string *php_io_terminal_read_stream_key( @@ -757,15 +945,18 @@ static zend_string *php_io_terminal_read_stream_key( if (handle == INVALID_HANDLE_VALUE || handle == NULL || (stream != NULL && stream->writepos > stream->readpos)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); return NULL; } if (!GetConsoleMode(handle, &mode)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); return NULL; } raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set console mode", 0); return NULL; } mode_changed = raw_mode != mode; @@ -819,6 +1010,9 @@ static zend_string *php_io_terminal_read_stream_key( if (result != NULL) { zend_string_release(result); } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore console mode", 0); + } return NULL; } @@ -945,21 +1139,18 @@ static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream { struct termios mode; struct termios raw_mode; - struct stat st; if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { return false; } - if (fstat(fd, &st) != 0 || !S_ISCHR(st.st_mode)) { + if (!php_io_terminal_get_identity(fd, &saved->identity)) { return false; } memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); saved->stream = fd; saved->mode = mode; - saved->tty_dev = st.st_rdev; - saved->tty_ino = st.st_ino; raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); @@ -1081,11 +1272,15 @@ static int php_io_terminal_read_byte( ssize_t bytes_read; if (stream != NULL && stream->writepos > stream->readpos) { - return php_stream_read(stream, (char *) byte, 1) == 1 ? 1 : -1; + size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + if (read_bytes == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; } if (poll_ctx == NULL) { - return -1; + return PHP_IO_TERMINAL_READ_ERROR; } if (timeout != NULL) { @@ -1113,7 +1308,7 @@ static int php_io_terminal_read_byte( break; } if (poll_result == 0) { - return 0; + return PHP_IO_TERMINAL_READ_TIMEOUT; } if (php_poll_get_error(poll_ctx) == PHP_POLL_ERR_INTERRUPTED) { @@ -1126,24 +1321,24 @@ static int php_io_terminal_read_byte( zend_hrtime_t elapsed_ns = zend_hrtime() - start_ns; if (UNEXPECTED(elapsed_ns == 0) || !php_io_terminal_timespec_subtract_elapsed(&remaining, elapsed_ns)) { - return 0; + return PHP_IO_TERMINAL_READ_TIMEOUT; } } continue; } - return -1; + return PHP_IO_TERMINAL_READ_ERROR; } bytes_read = read(fd, byte, 1); if (bytes_read == 0) { - return 0; + return PHP_IO_TERMINAL_READ_EOF; } if (bytes_read < 0) { - return -1; + return PHP_IO_TERMINAL_READ_ERROR; } - return 1; + return PHP_IO_TERMINAL_READ_SUCCESS; } static zend_string *php_io_terminal_finish_utf8_sequence( @@ -1162,7 +1357,15 @@ static zend_string *php_io_terminal_finish_utf8_sequence( const struct timespec *timeout = pending->length == initial_length ? first_timeout : sequence_timeout; int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); - if (result != 1) { + if (result == PHP_IO_TERMINAL_READ_TIMEOUT) { + return NULL; + } + if (result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } + if (result != PHP_IO_TERMINAL_READ_SUCCESS) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); return NULL; } @@ -1216,9 +1419,17 @@ static zend_string *php_io_terminal_key_from_escape_sequence( seq[seq_len++] = 0x1b; read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], sequence_timeout, false, NULL); - if (read_result != 1) { + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { return ZSTR_INIT_LITERAL("escape", false); } + if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + } seq_len++; if (seq[1] == '[') { @@ -1226,7 +1437,15 @@ static zend_string *php_io_terminal_key_from_escape_sequence( unsigned char next; read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, sequence_timeout, false, NULL); - if (read_result != 1) { + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } + if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + } break; } @@ -1264,7 +1483,7 @@ static zend_string *php_io_terminal_key_from_escape_sequence( #undef PHP_IO_TERMINAL_CSI_IS } else if (seq[1] == 'O' && seq_len < sizeof(seq)) { read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], sequence_timeout, false, NULL); - if (read_result == 1) { + if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { seq_len++; switch (seq[2]) { case 'P': return ZSTR_INIT_LITERAL("f1", false); @@ -1274,6 +1493,12 @@ static zend_string *php_io_terminal_key_from_escape_sequence( case 'H': return ZSTR_INIT_LITERAL("home", false); case 'F': return ZSTR_INIT_LITERAL("end", false); } + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; } } @@ -1325,11 +1550,13 @@ static zend_string *php_io_terminal_read_stream_key( php_poll_ctx *poll_ctx; if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); return NULL; } poll_ctx = php_io_terminal_create_poll_context(fd); if (poll_ctx == NULL) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to initialize terminal poll context", 0); return NULL; } @@ -1350,6 +1577,7 @@ static zend_string *php_io_terminal_read_stream_key( } #endif php_poll_destroy(poll_ctx); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); return NULL; } @@ -1360,8 +1588,12 @@ static zend_string *php_io_terminal_read_stream_key( resize_handler_installed ? &resize_generation : NULL); if (read_result == PHP_IO_TERMINAL_READ_RESIZE) { result = ZSTR_INIT_LITERAL("resize", false); - } else if (read_result == 1) { + } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { result = php_io_terminal_key_from_byte(fd, stream, poll_ctx, key, sequence_timeout, pending); + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); } } @@ -1375,6 +1607,9 @@ static zend_string *php_io_terminal_read_stream_key( zend_string_release(result); } php_poll_destroy(poll_ctx); + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + } return NULL; } @@ -1591,7 +1826,25 @@ static void php_io_terminal_create_terminal_size(zval *return_value, zend_long c /* Io\Terminal\TerminalSize methods */ PHP_METHOD(Io_Terminal_TerminalSize, __construct) { - zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\TerminalSize"); + zend_long cols, rows; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_LONG(cols) + Z_PARAM_LONG(rows) + ZEND_PARSE_PARAMETERS_END(); + + if (cols <= 0) { + zend_argument_value_error(1, "must be greater than 0"); + RETURN_THROWS(); + } + + if (rows <= 0) { + zend_argument_value_error(2, "must be greater than 0"); + RETURN_THROWS(); + } + + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "cols", sizeof("cols") - 1, cols); + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "rows", sizeof("rows") - 1, rows); } /* Io\Terminal\ModeToken methods */ @@ -1606,7 +1859,7 @@ PHP_METHOD(Io_Terminal_Terminal, __construct) zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\Terminal"); } -PHP_METHOD(Io_Terminal_Terminal, create) +PHP_METHOD(Io_Terminal_Terminal, fromStdio) { ZEND_PARSE_PARAMETERS_NONE(); @@ -1704,7 +1957,8 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) } if (!php_io_terminal_enable_stream_raw_mode(stream.native_stream, &saved)) { - RETURN_FALSE; + zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); + RETURN_THROWS(); } php_io_terminal_create_mode_token(return_value, &saved, stream.stream_resource); @@ -1734,13 +1988,27 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) RETURN_THROWS(); } + php_io_terminal_match_result match = php_io_terminal_terminal_matches_saved_mode(intern, &mode->saved); + if (match == PHP_IO_TERMINAL_MATCH_ERROR) { + RETURN_THROWS(); + } + if (match == PHP_IO_TERMINAL_MATCH_MISMATCH) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } + restored = php_io_terminal_release_mode_token(mode); - if (restored && intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { + if (!restored) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + RETURN_THROWS(); + } + + if (intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; } - RETURN_BOOL(restored); + RETURN_TRUE; } if (intern->active_mode_token != NULL) { @@ -1754,12 +2022,15 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) } restored = php_io_terminal_release_mode_token(mode); - if (restored) { - OBJ_RELEASE(intern->active_mode_token); - intern->active_mode_token = NULL; + if (!restored) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + RETURN_THROWS(); } - RETURN_BOOL(restored); + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + + RETURN_TRUE; } RETURN_FALSE; @@ -1881,6 +2152,13 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) ); #endif + if (EG(exception)) { + if (key != NULL) { + zend_string_release(key); + } + RETURN_THROWS(); + } + if (key == NULL) { RETURN_FALSE; } @@ -1978,6 +2256,9 @@ PHP_MINIT_FUNCTION(terminal) #if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) php_io_terminal_resize_mutex = tsrm_mutex_alloc(); #endif +#if !defined(PHP_WIN32) && !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) + php_io_terminal_ptsname_mutex = tsrm_mutex_alloc(); +#endif return SUCCESS; } @@ -1990,6 +2271,12 @@ PHP_MSHUTDOWN_FUNCTION(terminal) php_io_terminal_resize_mutex = NULL; } #endif +#if !defined(PHP_WIN32) && !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) + if (php_io_terminal_ptsname_mutex != NULL) { + tsrm_mutex_free(php_io_terminal_ptsname_mutex); + php_io_terminal_ptsname_mutex = NULL; + } +#endif return SUCCESS; } diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index 5346d0513427..22ed5c5b1497 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -46,7 +46,7 @@ enum Key public readonly int $cols; public readonly int $rows; - private function __construct() {} + public function __construct(int $cols, int $rows) {} } /** @@ -66,7 +66,7 @@ final class Terminal { private function __construct() {} - public static function create(): Terminal {} + public static function fromStdio(): Terminal {} /** * @param resource $input @@ -76,7 +76,7 @@ public static function fromStreams($input, $output = null): Terminal {} public function getSize(): TerminalSize|false {} - public function enableRawMode(): ModeToken|false {} + public function enableRawMode(): ModeToken {} public function restoreMode(?ModeToken $mode = null): bool {} diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 43d01afd90c0..0965bea988d7 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,17 +1,20 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: d469eb6a85a1b785b0f9df0657696de108706101 + * Stub hash: 919125ad09816c09ee488556d37f36f42818be80 * Has decl header: yes */ #include "zend_enum.h" -ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_TerminalSize___construct, 0, 0, 0) +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_TerminalSize___construct, 0, 0, 2) + ZEND_ARG_TYPE_INFO(0, cols, IS_LONG, 0) + ZEND_ARG_TYPE_INFO(0, rows, IS_LONG, 0) ZEND_END_ARG_INFO() -#define arginfo_class_Io_Terminal_ModeToken___construct arginfo_class_Io_Terminal_TerminalSize___construct +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_ModeToken___construct, 0, 0, 0) +ZEND_END_ARG_INFO() -#define arginfo_class_Io_Terminal_Terminal___construct arginfo_class_Io_Terminal_TerminalSize___construct +#define arginfo_class_Io_Terminal_Terminal___construct arginfo_class_Io_Terminal_ModeToken___construct -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_create, 0, 0, Io\\Terminal\\Terminal, 0) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_fromStdio, 0, 0, Io\\Terminal\\Terminal, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_fromStreams, 0, 1, Io\\Terminal\\Terminal, 0) @@ -22,7 +25,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_Terminal_getSize, 0, 0, Io\\Terminal\\TerminalSize, MAY_BE_FALSE) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_Terminal_enableRawMode, 0, 0, Io\\Terminal\\ModeToken, MAY_BE_FALSE) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_enableRawMode, 0, 0, Io\\Terminal\\ModeToken, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_restoreMode, 0, 0, _IS_BOOL, 0) @@ -40,7 +43,7 @@ ZEND_END_ARG_INFO() ZEND_METHOD(Io_Terminal_TerminalSize, __construct); ZEND_METHOD(Io_Terminal_ModeToken, __construct); ZEND_METHOD(Io_Terminal_Terminal, __construct); -ZEND_METHOD(Io_Terminal_Terminal, create); +ZEND_METHOD(Io_Terminal_Terminal, fromStdio); ZEND_METHOD(Io_Terminal_Terminal, fromStreams); ZEND_METHOD(Io_Terminal_Terminal, getSize); ZEND_METHOD(Io_Terminal_Terminal, enableRawMode); @@ -49,7 +52,7 @@ ZEND_METHOD(Io_Terminal_Terminal, readKey); ZEND_METHOD(Io_Terminal_Terminal, readSecret); static const zend_function_entry class_Io_Terminal_TerminalSize_methods[] = { - ZEND_ME(Io_Terminal_TerminalSize, __construct, arginfo_class_Io_Terminal_TerminalSize___construct, ZEND_ACC_PRIVATE) + ZEND_ME(Io_Terminal_TerminalSize, __construct, arginfo_class_Io_Terminal_TerminalSize___construct, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -60,7 +63,7 @@ static const zend_function_entry class_Io_Terminal_ModeToken_methods[] = { static const zend_function_entry class_Io_Terminal_Terminal_methods[] = { ZEND_ME(Io_Terminal_Terminal, __construct, arginfo_class_Io_Terminal_Terminal___construct, ZEND_ACC_PRIVATE) - ZEND_ME(Io_Terminal_Terminal, create, arginfo_class_Io_Terminal_Terminal_create, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Io_Terminal_Terminal, fromStdio, arginfo_class_Io_Terminal_Terminal_fromStdio, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Io_Terminal_Terminal, fromStreams, arginfo_class_Io_Terminal_Terminal_fromStreams, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Io_Terminal_Terminal, getSize, arginfo_class_Io_Terminal_Terminal_getSize, ZEND_ACC_PUBLIC) ZEND_ME(Io_Terminal_Terminal, enableRawMode, arginfo_class_Io_Terminal_Terminal_enableRawMode, ZEND_ACC_PUBLIC) diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index f3ab4852e3f9..a377005dd97a 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: d469eb6a85a1b785b0f9df0657696de108706101 */ + * Stub hash: 919125ad09816c09ee488556d37f36f42818be80 */ -#ifndef ZEND_IO_TERMINAL_DECL_d469eb6a85a1b785b0f9df0657696de108706101_H -#define ZEND_IO_TERMINAL_DECL_d469eb6a85a1b785b0f9df0657696de108706101_H +#ifndef ZEND_IO_TERMINAL_DECL_919125ad09816c09ee488556d37f36f42818be80_H +#define ZEND_IO_TERMINAL_DECL_919125ad09816c09ee488556d37f36f42818be80_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_d469eb6a85a1b785b0f9df0657696de108706101_H */ +#endif /* ZEND_IO_TERMINAL_DECL_919125ad09816c09ee488556d37f36f42818be80_H */ diff --git a/ext/standard/tests/terminal/terminal_controlling_tty.phpt b/ext/standard/tests/terminal/terminal_controlling_tty.phpt new file mode 100644 index 000000000000..236256dd0823 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_controlling_tty.phpt @@ -0,0 +1,49 @@ +--TEST-- +Io\Terminal\Terminal: /dev/tty controlling terminal identity +--SKIPIF-- + +--FILE-- +enableRawMode(); +var_dump($mTty instanceof ModeToken); + +// Descriptors to the same controlling terminal share identity +var_dump($tTty2->restoreMode($mTty)); + +// Mode was already restored; further restore returns false +var_dump($tTty1->restoreMode()); + +fclose($ttyFp2); +fclose($ttyFp1); +?> +--EXPECT-- +bool(true) +bool(true) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index 40b53e5b1499..819243bfc3c3 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: create, fromStreams, construction and serialization restrictions +Io\Terminal\Terminal: fromStdio, fromStreams, construction and serialization restrictions --FILE-- getMessage(), PHP_EOL; } -// Constructor privacy -foreach ([Terminal::class, TerminalSize::class, ModeToken::class] as $class) { +// Constructor privacy for Terminal and ModeToken +foreach ([Terminal::class, ModeToken::class] as $class) { try { new $class(); } catch (Throwable $e) { @@ -41,6 +41,28 @@ foreach ([Terminal::class, TerminalSize::class, ModeToken::class] as $class) { } } +// TerminalSize public constructor and validation +$size = new TerminalSize(80, 24); +var_dump($size->cols, $size->rows); + +foreach ([ + [0, 24], + [80, 0], + [-1, 24], + [80, -5], +] as [$cols, $rows]) { + try { + new TerminalSize($cols, $rows); + } catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; + } +} + +// TerminalSize serialization +$serialized = serialize($size); +$unserialized = unserialize($serialized); +var_dump($unserialized instanceof TerminalSize, $unserialized->cols, $unserialized->rows); + // Clone rejection foreach ([$t1, $t2] as $obj) { try { @@ -50,7 +72,7 @@ foreach ([$t1, $t2] as $obj) { } } -// Serialization rejection +// Serialization rejection for Terminal try { serialize($t1); } catch (Throwable $e) { @@ -68,8 +90,16 @@ object(Io\Terminal\Terminal)#3 (0) { TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given Error: Call to private Io\Terminal\Terminal::__construct() from global scope -Error: Call to private Io\Terminal\TerminalSize::__construct() from global scope Error: Call to private Io\Terminal\ModeToken::__construct() from global scope +int(80) +int(24) +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0 +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0 +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0 +ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0 +bool(true) +int(80) +int(24) Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal Exception: Serialization of 'Io\Terminal\Terminal' is not allowed diff --git a/ext/standard/tests/terminal/terminal_identity.phpt b/ext/standard/tests/terminal/terminal_identity.phpt new file mode 100644 index 000000000000..c4f650da30df --- /dev/null +++ b/ext/standard/tests/terminal/terminal_identity.phpt @@ -0,0 +1,94 @@ +--TEST-- +Io\Terminal\Terminal: POSIX terminal identity and unrelated PTY rejection +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$t1 = Terminal::fromStreams($pipes1[0]); +$t1_dup = Terminal::fromStreams($pipes1[1]); +$t2 = Terminal::fromStreams($pipes2[0]); + +$m1_dup = $t1->enableRawMode(); +var_dump($m1_dup instanceof ModeToken); +var_dump($t1_dup->restoreMode($m1_dup)); + +$m1 = $t1->enableRawMode(); +try { + $t2->restoreMode($m1); + echo "FAIL: unrelated PTY accepted token\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +var_dump($t1->restoreMode($m1)); +var_dump($t1->restoreMode()); + +fwrite($pipes1[0], "exit\n"); +fwrite($pipes2[0], "exit\n"); +unset($t1, $t1_dup, $t2); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); +proc_close($proc2); +?> +--EXPECT-- +bool(true) +bool(true) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt index 4d013f77591d..ed5e905094d7 100644 --- a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: overlapping raw-mode sessions and out-of-order restoration +Io\Terminal\Terminal: overlapping raw-mode sessions, ownership validation, and out-of-order restoration --SKIPIF-- ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + // Two separate Terminal instances referencing descriptors on the same PTY $t1 = Terminal::fromStreams($pipes[0]); $t2 = Terminal::fromStreams($pipes[1]); +$tUnrelated = Terminal::fromStreams($pipes2[0]); + +// Test 1: Same Terminal + token +$m = $t1->enableRawMode(); +var_dump($m instanceof ModeToken); +var_dump($t1->restoreMode($m)); + +// Test 2: Different Terminal on same device +$m1 = $t1->enableRawMode(); +var_dump($t2->restoreMode($m1)); -// Test 1: Out-of-order restore (m1 then m2) +// Test 3: Unrelated terminal rejection (ValueError) +$m1 = $t1->enableRawMode(); +try { + $tUnrelated->restoreMode($m1); + echo "FAIL: unrelated terminal accepted token\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} +var_dump($t1->restoreMode($m1)); + +// Test 4: Stale token after terminal destruction +$tTemp = Terminal::fromStreams($pipes[0]); +$mTemp = $tTemp->enableRawMode(); +unset($tTemp); // releases active mode token +try { + $t1->restoreMode($mTemp); + echo "FAIL: stale token after destruction accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Test 5: Out-of-order restore (m1 then m2) $m1 = $t1->enableRawMode(); $m2 = $t2->enableRawMode(); @@ -62,7 +103,7 @@ var_dump($t1->restoreMode($m1)); // Now restore m2 var_dump($t2->restoreMode($m2)); -// Test 2: In-order restore (m2 then m1) +// Test 6: In-order restore (m2 then m1) $m1 = $t1->enableRawMode(); $m2 = $t2->enableRawMode(); @@ -70,16 +111,27 @@ var_dump($t2->restoreMode($m2)); var_dump($t1->restoreMode($m1)); fwrite($pipes[0], "exit\n"); -unset($t1, $t2); +fwrite($pipes2[0], "exit\n"); +unset($t1, $t2, $tUnrelated); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} proc_close($proc); +proc_close($proc2); ?> --EXPECT-- bool(true) bool(true) bool(true) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() +bool(true) +bool(true) +bool(true) bool(true) bool(true) bool(true) diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt index ef10b9a1e7c0..4b9d0600cee0 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -82,8 +82,55 @@ try { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } +// Regression test: Failed no-argument restoreMode must not discard active token ownership +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); +$terminalFail = Terminal::fromStreams($pipes2[0]); +$tokenFail = $terminalFail->enableRawMode(); +var_dump($tokenFail instanceof ModeToken); + +// Close the underlying stream to force release_mode_token to fail +fclose($pipes2[0]); + +try { + $terminalFail->restoreMode(); + echo "FAIL: restoreMode on closed stream did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// A second call must attempt restoration again and throw TerminalException, +// rather than returning false due to discarded token ownership. +try { + $terminalFail->restoreMode(); + echo "FAIL: second restoreMode did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Operational stream failure with explicit token must not be misreported as ValueError +try { + $terminalFail->restoreMode($tokenFail); + echo "FAIL: restoreMode with token on closed stream did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +proc_terminate($proc2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + fwrite($pipes[0], "exit\n"); -unset($terminal, $token3); +unset($terminal, $token3, $terminalFail, $tokenFail); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } @@ -98,3 +145,7 @@ bool(true) bool(false) bool(true) ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() +bool(true) +Io\Terminal\TerminalException: Failed to restore terminal mode +Io\Terminal\TerminalException: Failed to restore terminal mode +TypeError: Io\Terminal\Terminal::restoreMode(): supplied resource is not a valid stream resource diff --git a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt index 09c1397e6844..f87c52a939bc 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: enableRawMode returns false for non-terminal streams +Io\Terminal\Terminal: enableRawMode throws TerminalException for non-terminal streams --FILE-- enableRawMode()); +try { + $terminal->enableRawMode(); + echo "FAIL: enableRawMode on non-terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} var_dump($terminal->restoreMode()); fclose($fp); ?> --EXPECT-- -bool(false) +Io\Terminal\TerminalException: Failed to enable terminal raw mode bool(false) diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt index 90294db06a8d..46d13f1746f1 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -6,7 +6,7 @@ Io\Terminal\Terminal: readKey with Time\Duration timeouts and parameter validati use Io\Terminal\Terminal; use Time\Duration; -$t = Terminal::create(); +$t = Terminal::fromStdio(); // Negative duration for timeout must throw ValueError try { @@ -23,13 +23,18 @@ try { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Non-terminal stream returns false immediately +// Non-terminal stream throws TerminalException $fp = fopen('php://temp', 'r+'); $nonTty = Terminal::fromStreams($fp); -var_dump($nonTty->readKey(Duration::fromSeconds(0))); +try { + $nonTty->readKey(Duration::fromSeconds(0)); + echo "FAIL: readKey on non-terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} ?> --EXPECT-- ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative -bool(false) +Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt new file mode 100644 index 000000000000..f9a19f9792e3 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -0,0 +1,118 @@ +--TEST-- +Io\Terminal\Terminal: readKey false vs TerminalException contracts on POSIX PTY +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +$terminal = Terminal::fromStreams($pipes[0]); +$terminal->enableRawMode(); + +// Contract 1: No input + zero timeout (non-blocking) returns false immediately +$start = hrtime(true); +$key = $terminal->readKey(Duration::fromSeconds(0)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($key === false); +var_dump($elapsed_ms < 20.0); + +// Contract 2: No input + small finite timeout returns false after duration +$start = hrtime(true); +$key = $terminal->readKey(Duration::fromMilliseconds(25)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($key === false); +var_dump($elapsed_ms >= 20.0); + +// Contract 3: Negative Duration throws ValueError +try { + $terminal->readKey(Duration::fromSeconds(1)->negate()); + echo "FAIL: negative timeout accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +try { + $terminal->readKey(Duration::fromSeconds(0), Duration::fromSeconds(1)->negate()); + echo "FAIL: negative sequence timeout accepted\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// Contract 4: Non-terminal input stream throws TerminalException +$fp = fopen('php://temp', 'r+'); +$nonTty = Terminal::fromStreams($fp); +try { + $nonTty->readKey(Duration::fromSeconds(0)); + echo "FAIL: non-terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} +fclose($fp); + +// Contract 5: EOF / stream termination throws TerminalException +fwrite($pipes[0], "exit\n"); +usleep(50000); // give child time to exit + +try { + $terminal->readKey(Duration::fromSeconds(1)); + echo "FAIL: readKey on closed/EOF terminal did not throw\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +unset($terminal); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal +Io\Terminal\TerminalException: %s on terminal input stream From ae756196a6163ab7bf94d4981d97e776b0df21e5 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 21:46:16 +0545 Subject: [PATCH 23/38] Use shared raw-mode ownership for terminals --- ext/standard/io_terminal.c | 504 ++++++++++-------- .../terminal_overlapping_raw_mode.phpt | 27 +- .../tests/terminal/terminal_raw_mode.phpt | 68 +-- 3 files changed, 326 insertions(+), 273 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 73c00c9bb4fc..bb9ced8d1dca 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -44,8 +44,6 @@ # include #endif -#define PHP_IO_TERMINAL_MODE_TOKEN_MAGIC "PHPTTY1" -#define PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN (sizeof(PHP_IO_TERMINAL_MODE_TOKEN_MAGIC) - 1) #define PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS 25 #if SIZEOF_TIME_T < 8 # define PHP_IO_TERMINAL_TIME_T_MAX INT32_MAX @@ -55,8 +53,10 @@ #ifdef PHP_WIN32 typedef HANDLE php_io_terminal_native_stream; +# define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM INVALID_HANDLE_VALUE #else typedef int php_io_terminal_native_stream; +# define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM (-1) typedef struct php_io_terminal_utf8_pending { unsigned char bytes[4]; @@ -79,30 +79,30 @@ typedef struct php_io_terminal_identity { } php_io_terminal_identity; #endif -typedef struct php_io_terminal_saved_mode { - char magic[PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN]; - php_io_terminal_native_stream stream; +typedef struct php_io_terminal_shared_mode { + php_io_terminal_identity identity; + php_io_terminal_native_stream restore_stream; #ifdef PHP_WIN32 - DWORD mode; + DWORD saved_mode; #else - struct termios mode; + struct termios saved_mode; #endif - php_io_terminal_identity identity; -} php_io_terminal_saved_mode; + uint32_t lease_count; + struct php_io_terminal_shared_mode *prev; + struct php_io_terminal_shared_mode *next; +} php_io_terminal_shared_mode; typedef struct php_io_terminal_mode_token_object { - php_io_terminal_saved_mode saved; - zval stream_resource; - struct php_io_terminal_mode_token_object *active_prev; - struct php_io_terminal_mode_token_object *active_next; + php_io_terminal_shared_mode *shared; bool valid; - bool tracked; zend_object std; } php_io_terminal_mode_token_object; typedef struct php_io_terminal_object { zval input_stream_val; zval output_stream_val; + php_io_terminal_identity identity; + bool has_identity; zend_object *active_mode_token; zend_long last_cols; zend_long last_rows; @@ -131,7 +131,7 @@ static zend_class_entry *php_io_terminal_mode_token_ce; static zend_class_entry *php_io_terminal_terminal_ce; static zend_object_handlers php_io_terminal_mode_token_handlers; static zend_object_handlers php_io_terminal_object_handlers; -ZEND_TLS php_io_terminal_mode_token_object *php_io_terminal_active_mode_tokens; +ZEND_TLS php_io_terminal_shared_mode *php_io_terminal_active_shared_modes; #if !defined(PHP_WIN32) # if defined(HAVE_PTSNAME_R) || defined(_GNU_SOURCE) || defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__) @@ -267,6 +267,19 @@ static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream #endif } +static bool php_io_terminal_native_stream_is_tty(php_io_terminal_native_stream stream) +{ + if (!php_io_terminal_native_stream_is_valid(stream)) { + return false; + } +#ifdef PHP_WIN32 + DWORD mode; + return GetConsoleMode(stream, &mode) != 0; +#else + return isatty(stream) == 1; +#endif +} + #ifdef PHP_WIN32 static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity) { @@ -397,160 +410,164 @@ static bool php_io_terminal_identities_match(const php_io_terminal_identity *fir } #endif -static bool php_io_terminal_mode_streams_match(const php_io_terminal_saved_mode *first, const php_io_terminal_saved_mode *second) -{ - return php_io_terminal_identities_match(&first->identity, &second->identity); -} - -static bool php_io_terminal_mode_token_stream_is_valid(const php_io_terminal_mode_token_object *mode) +#ifdef PHP_WIN32 +static php_io_terminal_native_stream php_io_terminal_dup_stream(php_io_terminal_native_stream handle) { - php_io_terminal_identity current_identity; + HANDLE current_process = GetCurrentProcess(); + HANDLE new_handle = INVALID_HANDLE_VALUE; - if (!php_io_terminal_native_stream_is_valid(mode->saved.stream)) { - return false; + if (DuplicateHandle(current_process, handle, current_process, &new_handle, 0, FALSE, DUPLICATE_SAME_ACCESS)) { + return new_handle; } - if (!php_io_terminal_get_identity(mode->saved.stream, ¤t_identity)) { - return false; - } + new_handle = CreateFileW( + L"CONIN$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, + OPEN_EXISTING, + 0, + NULL + ); - if (!php_io_terminal_identities_match(&mode->saved.identity, ¤t_identity)) { - return false; + if (new_handle != INVALID_HANDLE_VALUE) { + return new_handle; } - if (!Z_ISUNDEF(mode->stream_resource)) { - php_stream *stream; - php_io_terminal_native_stream native_stream; - php_io_terminal_identity res_identity; + return INVALID_HANDLE_VALUE; +} - if (Z_TYPE(mode->stream_resource) != IS_RESOURCE) { - return false; - } +static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream) +{ + if (stream != INVALID_HANDLE_VALUE && stream != NULL) { + CloseHandle(stream); + } +} +#else +static php_io_terminal_native_stream php_io_terminal_dup_stream(php_io_terminal_native_stream fd) +{ + int new_fd = -1; - stream = (php_stream *) zend_fetch_resource2( - Z_RES(mode->stream_resource), - NULL, - php_file_le_stream(), - php_file_le_pstream() - ); - if (stream == NULL) { - return false; - } +#if defined(F_DUPFD_CLOEXEC) + new_fd = fcntl(fd, F_DUPFD_CLOEXEC, 0); + if (new_fd >= 0) { + return new_fd; + } +#endif - native_stream = php_io_terminal_native_stream_from_php_stream(stream); - if (!php_io_terminal_native_stream_is_valid(native_stream)) { - return false; + new_fd = dup(fd); + if (new_fd >= 0) { +#if defined(FD_CLOEXEC) + int flags = fcntl(new_fd, F_GETFD); + if (flags >= 0) { + fcntl(new_fd, F_SETFD, flags | FD_CLOEXEC); } +#endif + return new_fd; + } - if (!php_io_terminal_get_identity(native_stream, &res_identity)) { - return false; - } + return -1; +} - if (!php_io_terminal_identities_match(&mode->saved.identity, &res_identity)) { - return false; - } +static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream) +{ + if (stream >= 0) { + close(stream); } +} +#endif - return true; +static php_io_terminal_shared_mode *php_io_terminal_find_shared_mode(const php_io_terminal_identity *identity) +{ + php_io_terminal_shared_mode *curr = php_io_terminal_active_shared_modes; + while (curr != NULL) { + if (php_io_terminal_identities_match(&curr->identity, identity)) { + return curr; + } + curr = curr->next; + } + return NULL; } -static bool php_io_terminal_restore_stream_mode(const php_io_terminal_saved_mode *saved) +static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mode *shared) { - if (!php_io_terminal_native_stream_is_valid(saved->stream)) { + if (!php_io_terminal_native_stream_is_valid(shared->restore_stream)) { return false; } #ifdef PHP_WIN32 - return SetConsoleMode(saved->stream, saved->mode) != 0; + return SetConsoleMode(shared->restore_stream, shared->saved_mode) != 0; #else - return tcsetattr(saved->stream, TCSANOW, &saved->mode) == 0; + return tcsetattr(shared->restore_stream, TCSANOW, &shared->saved_mode) == 0; #endif } -static void php_io_terminal_untrack_mode_token(php_io_terminal_mode_token_object *mode) +static void php_io_terminal_destroy_shared_mode(php_io_terminal_shared_mode *shared) { - if (!mode->tracked) { - return; - } - - if (mode->active_prev != NULL) { - mode->active_prev->active_next = mode->active_next; + if (shared->prev != NULL) { + shared->prev->next = shared->next; } else { - php_io_terminal_active_mode_tokens = mode->active_next; + php_io_terminal_active_shared_modes = shared->next; } - if (mode->active_next != NULL) { - mode->active_next->active_prev = mode->active_prev; + if (shared->next != NULL) { + shared->next->prev = shared->prev; } - mode->active_prev = NULL; - mode->active_next = NULL; - mode->tracked = false; -} + shared->prev = NULL; + shared->next = NULL; -static void php_io_terminal_track_mode_token(php_io_terminal_mode_token_object *mode) -{ - mode->active_prev = NULL; - mode->active_next = php_io_terminal_active_mode_tokens; - if (php_io_terminal_active_mode_tokens != NULL) { - php_io_terminal_active_mode_tokens->active_prev = mode; + if (php_io_terminal_native_stream_is_valid(shared->restore_stream)) { + php_io_terminal_close_native_stream(shared->restore_stream); + shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; } - php_io_terminal_active_mode_tokens = mode; - mode->tracked = true; + + efree(shared); } -static bool php_io_terminal_release_mode_token(php_io_terminal_mode_token_object *mode) +static bool php_io_terminal_release_token_lease(php_io_terminal_mode_token_object *token_obj, const char **error_msg) { - php_io_terminal_mode_token_object *candidate = php_io_terminal_active_mode_tokens; - php_io_terminal_mode_token_object *newer = NULL; - bool restored; + php_io_terminal_shared_mode *shared = token_obj->shared; - while (candidate != NULL && candidate != mode) { - if (candidate->valid && php_io_terminal_mode_streams_match(&candidate->saved, &mode->saved)) { - newer = candidate; + if (!token_obj->valid || shared == NULL) { + if (error_msg != NULL) { + *error_msg = "Terminal mode token is not active"; } - candidate = candidate->active_next; - } - - if (candidate != mode) { - newer = NULL; + return false; } - if (newer != NULL) { - newer->saved.mode = mode->saved.mode; - php_io_terminal_untrack_mode_token(mode); - memset(&mode->saved, 0, sizeof(mode->saved)); - mode->valid = false; + if (shared->lease_count > 1) { + shared->lease_count--; + token_obj->valid = false; + token_obj->shared = NULL; return true; } - if (!php_io_terminal_mode_token_stream_is_valid(mode)) { + if (!php_io_terminal_restore_shared_mode(shared)) { + if (error_msg != NULL) { + *error_msg = "Failed to restore terminal mode"; + } return false; } - restored = php_io_terminal_restore_stream_mode(&mode->saved); - if (restored) { - php_io_terminal_untrack_mode_token(mode); - memset(&mode->saved, 0, sizeof(mode->saved)); - mode->valid = false; - } + token_obj->valid = false; + token_obj->shared = NULL; - return restored; + php_io_terminal_destroy_shared_mode(shared); + return true; } static void php_io_terminal_mode_token_free_obj(zend_object *object) { php_io_terminal_mode_token_object *intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(object); - if (intern->valid && memcmp(intern->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - php_io_terminal_release_mode_token(intern); + if (intern->valid && intern->shared != NULL) { + const char *err = NULL; + if (!php_io_terminal_release_token_lease(intern, &err)) { + intern->valid = false; + intern->shared = NULL; + } } - php_io_terminal_untrack_mode_token(intern); - - zval_ptr_dtor(&intern->stream_resource); - - memset(&intern->saved, 0, sizeof(intern->saved)); - intern->valid = false; zend_object_std_dtor(&intern->std); } @@ -559,12 +576,8 @@ static zend_object *php_io_terminal_mode_token_create_object(zend_class_entry *c { php_io_terminal_mode_token_object *intern = zend_object_alloc(sizeof(*intern), ce); - memset(&intern->saved, 0, sizeof(intern->saved)); - ZVAL_UNDEF(&intern->stream_resource); - intern->active_prev = NULL; - intern->active_next = NULL; + intern->shared = NULL; intern->valid = false; - intern->tracked = false; zend_object_std_init(&intern->std, ce); object_properties_init(&intern->std, ce); @@ -573,18 +586,14 @@ static zend_object *php_io_terminal_mode_token_create_object(zend_class_entry *c return &intern->std; } -static void php_io_terminal_create_mode_token(zval *return_value, const php_io_terminal_saved_mode *saved, zval *stream_resource) +static void php_io_terminal_create_mode_token(zval *return_value, php_io_terminal_shared_mode *shared) { php_io_terminal_mode_token_object *intern; object_init_ex(return_value, php_io_terminal_mode_token_ce); intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value); - intern->saved = *saved; - if (stream_resource != NULL && !Z_ISUNDEF_P(stream_resource)) { - ZVAL_COPY(&intern->stream_resource, stream_resource); - } + intern->shared = shared; intern->valid = true; - php_io_terminal_track_mode_token(intern); } static void php_io_terminal_free_obj(zend_object *object) @@ -592,10 +601,6 @@ static void php_io_terminal_free_obj(zend_object *object) php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(object); if (intern->active_mode_token != NULL) { - php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); - if (mode->valid && memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - php_io_terminal_release_mode_token(mode); - } OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; } @@ -612,10 +617,13 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) ZVAL_UNDEF(&intern->input_stream_val); ZVAL_UNDEF(&intern->output_stream_val); + memset(&intern->identity, 0, sizeof(intern->identity)); + intern->has_identity = false; intern->active_mode_token = NULL; intern->last_cols = 0; intern->last_rows = 0; intern->has_last_size = false; + #ifdef PHP_WIN32 memset(&intern->pending_key, 0, sizeof(intern->pending_key)); intern->pending_key_high_surrogate = 0; @@ -631,6 +639,7 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) return &intern->std; } + static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream) { #ifdef PHP_WIN32 @@ -662,9 +671,10 @@ static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stre #endif } -static bool php_io_terminal_stream_target_init( +static bool php_io_terminal_stream_target_init_ex( zval *stream_arg, bool is_input, + bool quiet, php_io_terminal_stream_target *target ) { @@ -683,7 +693,7 @@ static bool php_io_terminal_stream_target_init( if (Z_TYPE_P(stream_arg) == IS_RESOURCE) { target->php_stream = (php_stream *) zend_fetch_resource2( Z_RES_P(stream_arg), - "stream", + quiet ? NULL : "stream", php_file_le_stream(), php_file_le_pstream() ); @@ -700,77 +710,61 @@ static bool php_io_terminal_stream_target_init( return false; } -typedef enum php_io_terminal_match_result { - PHP_IO_TERMINAL_MATCH_OK, - PHP_IO_TERMINAL_MATCH_MISMATCH, - PHP_IO_TERMINAL_MATCH_ERROR, -} php_io_terminal_match_result; - -static php_io_terminal_match_result php_io_terminal_terminal_matches_saved_mode( - php_io_terminal_object *intern, - const php_io_terminal_saved_mode *saved +static zend_always_inline bool php_io_terminal_stream_target_init( + zval *stream_arg, + bool is_input, + php_io_terminal_stream_target *target ) { - php_io_terminal_stream_target stream; - php_io_terminal_identity current_identity; - - if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { - if (!EG(exception)) { - zend_throw_exception(php_io_terminal_exception_ce, "Cannot restore terminal mode: terminal stream is closed or invalid", 0); - } - return PHP_IO_TERMINAL_MATCH_ERROR; - } - - if (!php_io_terminal_native_stream_is_valid(stream.native_stream)) { - if (!EG(exception)) { - zend_throw_exception(php_io_terminal_exception_ce, "Cannot restore terminal mode: terminal stream is invalid", 0); - } - return PHP_IO_TERMINAL_MATCH_ERROR; - } - - if (!php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) { - if (!EG(exception)) { - zend_throw_exception(php_io_terminal_exception_ce, "Failed to inspect terminal device", 0); - } - return PHP_IO_TERMINAL_MATCH_ERROR; - } - - if (php_io_terminal_identities_match(¤t_identity, &saved->identity)) { - return PHP_IO_TERMINAL_MATCH_OK; - } - - return PHP_IO_TERMINAL_MATCH_MISMATCH; + return php_io_terminal_stream_target_init_ex(stream_arg, is_input, false, target); } #ifdef PHP_WIN32 static DWORD php_io_terminal_make_raw_mode(DWORD mode) { - return (mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)); + return (mode & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)) | ENABLE_WINDOW_INPUT; } -static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream handle, php_io_terminal_saved_mode *saved) +static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( + php_io_terminal_native_stream handle, + const php_io_terminal_identity *identity +) { - DWORD mode; - DWORD raw_mode; + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); + if (shared != NULL) { + shared->lease_count++; + return shared; + } + DWORD mode; if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) { - return false; + return NULL; } - if (!php_io_terminal_get_identity(handle, &saved->identity)) { - return false; + php_io_terminal_native_stream restore_stream = php_io_terminal_dup_stream(handle); + if (!php_io_terminal_native_stream_is_valid(restore_stream)) { + return NULL; } - memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); - saved->stream = handle; - saved->mode = mode; - - raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; + DWORD raw_mode = php_io_terminal_make_raw_mode(mode); if (!SetConsoleMode(handle, raw_mode)) { - return false; + php_io_terminal_close_native_stream(restore_stream); + return NULL; } - return true; + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 1; + shared->prev = NULL; + shared->next = php_io_terminal_active_shared_modes; + if (php_io_terminal_active_shared_modes != NULL) { + php_io_terminal_active_shared_modes->prev = shared; + } + php_io_terminal_active_shared_modes = shared; + + return shared; } static bool php_io_terminal_stream_size(php_io_terminal_native_stream handle, zend_long *columns, zend_long *rows) @@ -1135,31 +1129,48 @@ static void php_io_terminal_make_raw_mode(struct termios *mode) mode->c_cc[VTIME] = 0; } -static bool php_io_terminal_enable_stream_raw_mode(php_io_terminal_native_stream fd, php_io_terminal_saved_mode *saved) +static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( + php_io_terminal_native_stream fd, + const php_io_terminal_identity *identity +) { - struct termios mode; - struct termios raw_mode; + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); + if (shared != NULL) { + shared->lease_count++; + return shared; + } + struct termios mode; if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { - return false; + return NULL; } - if (!php_io_terminal_get_identity(fd, &saved->identity)) { - return false; + php_io_terminal_native_stream restore_stream = php_io_terminal_dup_stream(fd); + if (!php_io_terminal_native_stream_is_valid(restore_stream)) { + return NULL; } - memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); - saved->stream = fd; - saved->mode = mode; - - raw_mode = mode; + struct termios raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { - return false; + php_io_terminal_close_native_stream(restore_stream); + return NULL; } - return true; + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 1; + shared->prev = NULL; + shared->next = php_io_terminal_active_shared_modes; + if (php_io_terminal_active_shared_modes != NULL) { + php_io_terminal_active_shared_modes->prev = shared; + } + php_io_terminal_active_shared_modes = shared; + + return shared; } static bool php_io_terminal_stream_size(php_io_terminal_native_stream fd, zend_long *columns, zend_long *rows) @@ -1861,9 +1872,18 @@ PHP_METHOD(Io_Terminal_Terminal, __construct) PHP_METHOD(Io_Terminal_Terminal, fromStdio) { + php_io_terminal_object *intern; + php_io_terminal_stream_target target; + ZEND_PARSE_PARAMETERS_NONE(); object_init_ex(return_value, php_io_terminal_terminal_ce); + intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); + + if (php_io_terminal_stream_target_init(NULL, true, &target) + && php_io_terminal_native_stream_is_valid(target.native_stream)) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } } PHP_METHOD(Io_Terminal_Terminal, fromStreams) @@ -1905,6 +1925,10 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) } else { ZVAL_COPY(&intern->output_stream_val, input_arg); } + + if (php_io_terminal_native_stream_is_valid(target.native_stream)) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } } PHP_METHOD(Io_Terminal_Terminal, getSize) @@ -1937,32 +1961,41 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) { php_io_terminal_object *intern; php_io_terminal_stream_target stream; - php_io_terminal_saved_mode saved; + php_io_terminal_identity identity; + php_io_terminal_shared_mode *shared; ZEND_PARSE_PARAMETERS_NONE(); intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); - if (intern->active_mode_token != NULL) { - php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); - if (mode->valid) { - RETURN_OBJ_COPY(intern->active_mode_token); - } - OBJ_RELEASE(intern->active_mode_token); - intern->active_mode_token = NULL; + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); } - if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + if (!php_io_terminal_native_stream_is_valid(stream.native_stream) || !php_io_terminal_native_stream_is_tty(stream.native_stream)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); + RETURN_THROWS(); + } + + if (!php_io_terminal_get_identity(stream.native_stream, &identity)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); RETURN_THROWS(); } - if (!php_io_terminal_enable_stream_raw_mode(stream.native_stream, &saved)) { + intern->identity = identity; + intern->has_identity = true; + + shared = php_io_terminal_acquire_raw_mode(stream.native_stream, &identity); + if (shared == NULL) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); RETURN_THROWS(); } - php_io_terminal_create_mode_token(return_value, &saved, stream.stream_resource); + php_io_terminal_create_mode_token(return_value, shared); + if (intern->active_mode_token != NULL) { + OBJ_RELEASE(intern->active_mode_token); + } intern->active_mode_token = Z_OBJ_P(return_value); GC_ADDREF(intern->active_mode_token); } @@ -1981,25 +2014,36 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token); - bool restored; - if (!mode->valid || memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { - zend_argument_value_error(1, "must be an active terminal mode token returned by Io\\Terminal\\Terminal::enableRawMode()"); + if (!mode->valid || mode->shared == NULL) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); RETURN_THROWS(); } - php_io_terminal_match_result match = php_io_terminal_terminal_matches_saved_mode(intern, &mode->saved); - if (match == PHP_IO_TERMINAL_MATCH_ERROR) { - RETURN_THROWS(); + bool matches = false; + if (intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { + matches = true; + } else { + php_io_terminal_stream_target stream; + php_io_terminal_identity current_identity; + + if (php_io_terminal_stream_target_init_ex(&intern->input_stream_val, true, true, &stream) + && php_io_terminal_native_stream_is_valid(stream.native_stream) + && php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) { + matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity); + } else if (intern->has_identity) { + matches = php_io_terminal_identities_match(&intern->identity, &mode->shared->identity); + } } - if (match == PHP_IO_TERMINAL_MATCH_MISMATCH) { + + if (!matches) { zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); RETURN_THROWS(); } - restored = php_io_terminal_release_mode_token(mode); - if (!restored) { - zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + const char *err = NULL; + if (!php_io_terminal_release_token_lease(mode, &err)) { + zend_throw_exception(php_io_terminal_exception_ce, err != NULL ? err : "Failed to restore terminal mode", 0); RETURN_THROWS(); } @@ -2013,17 +2057,16 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) if (intern->active_mode_token != NULL) { php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); - bool restored; - if (!mode->valid || memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) != 0) { + if (!mode->valid || mode->shared == NULL) { OBJ_RELEASE(intern->active_mode_token); intern->active_mode_token = NULL; RETURN_FALSE; } - restored = php_io_terminal_release_mode_token(mode); - if (!restored) { - zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + const char *err = NULL; + if (!php_io_terminal_release_token_lease(mode, &err)) { + zend_throw_exception(php_io_terminal_exception_ce, err != NULL ? err : "Failed to restore terminal mode", 0); RETURN_THROWS(); } @@ -2283,23 +2326,16 @@ PHP_MSHUTDOWN_FUNCTION(terminal) PHP_RINIT_FUNCTION(terminal) { - php_io_terminal_active_mode_tokens = NULL; + php_io_terminal_active_shared_modes = NULL; return SUCCESS; } PHP_RSHUTDOWN_FUNCTION(terminal) { - while (php_io_terminal_active_mode_tokens != NULL) { - php_io_terminal_mode_token_object *mode = php_io_terminal_active_mode_tokens; - if (mode->valid && memcmp(mode->saved.magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN) == 0) { - if (!php_io_terminal_release_mode_token(mode)) { - php_io_terminal_untrack_mode_token(mode); - memset(&mode->saved, 0, sizeof(mode->saved)); - mode->valid = false; - } - } else { - php_io_terminal_untrack_mode_token(mode); - } + while (php_io_terminal_active_shared_modes != NULL) { + php_io_terminal_shared_mode *shared = php_io_terminal_active_shared_modes; + php_io_terminal_restore_shared_mode(shared); + php_io_terminal_destroy_shared_mode(shared); } return SUCCESS; } diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt index ed5e905094d7..e9f72b100580 100644 --- a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -79,13 +79,18 @@ try { } var_dump($t1->restoreMode($m1)); -// Test 4: Stale token after terminal destruction +// Test 4: Token survives Terminal destruction when held externally $tTemp = Terminal::fromStreams($pipes[0]); $mTemp = $tTemp->enableRawMode(); -unset($tTemp); // releases active mode token +unset($tTemp); // Drops Terminal reference, but $mTemp remains active + +// $mTemp is still active and can be restored through another Terminal on the same device +var_dump($t1->restoreMode($mTemp)); + +// After restoration, the token is consumed; reusing it throws ValueError try { $t1->restoreMode($mTemp); - echo "FAIL: stale token after destruction accepted\n"; + echo "FAIL: stale token after restoration accepted\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -110,9 +115,19 @@ $m2 = $t2->enableRawMode(); var_dump($t2->restoreMode($m2)); var_dump($t1->restoreMode($m1)); +// Test 7: Destroying the final externally-held token restores canonical mode +$tLive = Terminal::fromStreams($pipes[0]); +$mHeld = $tLive->enableRawMode(); +unset($tLive); // Terminal dropped; $mHeld keeps raw mode active +unset($mHeld); // Final token destroyed; ModeToken destructor restores canonical mode + +// Terminal is back in canonical mode; further restore returns false +$tVerify = Terminal::fromStreams($pipes[0]); +var_dump($tVerify->restoreMode()); + fwrite($pipes[0], "exit\n"); fwrite($pipes2[0], "exit\n"); -unset($t1, $t2, $tUnrelated); +unset($t1, $t2, $tUnrelated, $tVerify); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } @@ -128,10 +143,12 @@ bool(true) bool(true) ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() +bool(true) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) bool(true) bool(true) bool(true) bool(true) +bool(false) diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt index 4b9d0600cee0..c9bce6519bc8 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -68,21 +68,30 @@ var_dump($token2 instanceof ModeToken); var_dump($terminal->restoreMode()); // Restores active token var_dump($terminal->restoreMode()); // Already restored -> returns false -// Auto-restoration on Terminal object destruction +// Auto-restoration on Terminal object destruction when token is unreferenced $terminal2 = Terminal::fromStreams($pipes[0]); -$token3 = $terminal2->enableRawMode(); +$terminal2->enableRawMode(); +unset($terminal2); // Terminal destructor drops reference; ModeToken destructor restores mode + +// Token survives Terminal object destruction when held externally +$terminal3 = Terminal::fromStreams($pipes[0]); +$token3 = $terminal3->enableRawMode(); var_dump($token3 instanceof ModeToken); -unset($terminal2); // Terminal destructor releases active mode token +unset($terminal3); // Drops Terminal reference, but $token3 is held externally + +// Restoring the externally held token through another Terminal on the same terminal succeeds +var_dump($terminal->restoreMode($token3)); -// Restoring with the token from the destroyed terminal must throw ValueError +// Once restored, reusing the token throws ValueError try { $terminal->restoreMode($token3); - echo "FAIL: token from destroyed terminal was accepted\n"; + echo "FAIL: consumed token was accepted\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Regression test: Failed no-argument restoreMode must not discard active token ownership +// Closing the original PHP stream after enableRawMode() does NOT prevent successful restoration +// because the shared record owns its own duplicated restoration descriptor $proc2 = proc_open( [PHP_BINARY, '-r', 'fgets(STDIN);'], [ @@ -92,36 +101,26 @@ $proc2 = proc_open( ], $pipes2, ); -$terminalFail = Terminal::fromStreams($pipes2[0]); -$tokenFail = $terminalFail->enableRawMode(); -var_dump($tokenFail instanceof ModeToken); +$terminalClosed = Terminal::fromStreams($pipes2[0]); +$tokenClosed = $terminalClosed->enableRawMode(); +var_dump($tokenClosed instanceof ModeToken); -// Close the underlying stream to force release_mode_token to fail +// Close the underlying stream fclose($pipes2[0]); -try { - $terminalFail->restoreMode(); - echo "FAIL: restoreMode on closed stream did not throw\n"; -} catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; -} +// Restoration succeeds via the record's owned descriptor +var_dump($terminalClosed->restoreMode($tokenClosed)); -// A second call must attempt restoration again and throw TerminalException, -// rather than returning false due to discarded token ownership. +// Token is now consumed; reusing it throws ValueError try { - $terminalFail->restoreMode(); - echo "FAIL: second restoreMode did not throw\n"; + $terminalClosed->restoreMode($tokenClosed); + echo "FAIL: consumed token was accepted\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Operational stream failure with explicit token must not be misreported as ValueError -try { - $terminalFail->restoreMode($tokenFail); - echo "FAIL: restoreMode with token on closed stream did not throw\n"; -} catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; -} +// Further restoreMode() returns false +var_dump($terminalClosed->restoreMode()); proc_terminate($proc2); foreach ($pipes2 as $pipe) { @@ -130,22 +129,23 @@ foreach ($pipes2 as $pipe) { proc_close($proc2); fwrite($pipes[0], "exit\n"); -unset($terminal, $token3, $terminalFail, $tokenFail); +unset($terminal, $token3, $terminalClosed, $tokenClosed); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } proc_close($proc); ?> ---EXPECTF-- +--EXPECT-- enableRawMode: ModeToken bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) bool(false) bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token returned by Io\Terminal\Terminal::enableRawMode() bool(true) -Io\Terminal\TerminalException: Failed to restore terminal mode -Io\Terminal\TerminalException: Failed to restore terminal mode -TypeError: Io\Terminal\Terminal::restoreMode(): supplied resource is not a valid stream resource +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(true) +bool(true) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +bool(false) From c9a2813d118184c5bb06e6a1c2735ab06498f77e Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 28 Sep 2026 21:50:38 +0545 Subject: [PATCH 24/38] Fix terminal_read_key_contracts expectation on Linux PTY EIO --- ext/standard/tests/terminal/terminal_read_key_contracts.phpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt index f9a19f9792e3..ba3f63d0debb 100644 --- a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -115,4 +115,4 @@ bool(true) ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal -Io\Terminal\TerminalException: %s on terminal input stream +Io\Terminal\TerminalException: %s terminal input stream From 87edddc15715fe50dff7c447e5e1f23913fda610 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 01:33:59 +0545 Subject: [PATCH 25/38] Address Io\Terminal review feedback and harden terminal contracts - Check EG(exception) after readonly property assignment in TerminalSize::__construct - Instantiate TerminalSize with object_init_with_constructor in Terminal::getSize - Match POSIX/Windows terminal identities by unique device/handle, avoiding raw descriptor reuse - Verify actual terminal state after tcsetattr and GetConsoleMode - Protect readSecret against NULL deref on disconnect, abort on control/escape sequences, and preserve fragmented UTF-8 across boundaries - Account for timeouts with zend_hrtime monotonic deadline across escape and multibyte decoding - Narrow variable declarations and simplify ZTS mutex macro guards --- ext/standard/io_terminal.c | 451 +++++++++++------- .../tests/terminal/terminal_create.phpt | 8 + .../tests/terminal/terminal_identity.phpt | 33 +- .../terminal_read_secret_control.phpt | 131 ++++- 4 files changed, 428 insertions(+), 195 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index bb9ced8d1dca..5a22aaa79d34 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -45,11 +45,14 @@ #endif #define PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS 25 -#if SIZEOF_TIME_T < 8 -# define PHP_IO_TERMINAL_TIME_T_MAX INT32_MAX -#else -# define PHP_IO_TERMINAL_TIME_T_MAX INT64_MAX -#endif + +static inline time_t php_io_terminal_clamp_duration_seconds(uint64_t seconds) +{ + if (sizeof(time_t) < 8) { + return seconds > (uint64_t) INT32_MAX ? (time_t) INT32_MAX : (time_t) seconds; + } + return seconds > (uint64_t) INT64_MAX ? (time_t) INT64_MAX : (time_t) seconds; +} #ifdef PHP_WIN32 typedef HANDLE php_io_terminal_native_stream; @@ -58,6 +61,20 @@ typedef HANDLE php_io_terminal_native_stream; typedef int php_io_terminal_native_stream; # define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM (-1) +static inline zend_hrtime_t php_io_terminal_timespec_to_ns(const struct timespec *ts) +{ + if (ts == NULL) { + return 0; + } + return ((zend_hrtime_t) ts->tv_sec * 1000000000ULL) + (zend_hrtime_t) ts->tv_nsec; +} + +static inline void php_io_terminal_ns_to_timespec(zend_hrtime_t ns, struct timespec *ts) +{ + ts->tv_sec = (time_t) (ns / 1000000000ULL); + ts->tv_nsec = (long) (ns % 1000000000ULL); +} + typedef struct php_io_terminal_utf8_pending { unsigned char bytes[4]; size_t length; @@ -68,6 +85,7 @@ typedef struct php_io_terminal_utf8_pending { #ifdef PHP_WIN32 typedef struct php_io_terminal_identity { php_io_terminal_native_stream stream; + bool is_console; } php_io_terminal_identity; #else typedef struct php_io_terminal_identity { @@ -139,6 +157,11 @@ ZEND_TLS php_io_terminal_shared_mode *php_io_terminal_active_shared_modes; # endif # if !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) static MUTEX_T php_io_terminal_ptsname_mutex = NULL; +# define PHP_IO_TERMINAL_PTSNAME_LOCK() do { if (php_io_terminal_ptsname_mutex) tsrm_mutex_lock(php_io_terminal_ptsname_mutex); } while (0) +# define PHP_IO_TERMINAL_PTSNAME_UNLOCK() do { if (php_io_terminal_ptsname_mutex) tsrm_mutex_unlock(php_io_terminal_ptsname_mutex); } while (0) +# else +# define PHP_IO_TERMINAL_PTSNAME_LOCK() +# define PHP_IO_TERMINAL_PTSNAME_UNLOCK() # endif #define PHP_IO_TERMINAL_READ_EOF (-2) @@ -151,27 +174,14 @@ static MUTEX_T php_io_terminal_ptsname_mutex = NULL; static volatile sig_atomic_t php_io_terminal_resize_generation = 0; static unsigned int php_io_terminal_resize_readers = 0; static struct sigaction php_io_terminal_previous_resize_action; -#ifdef ZTS +# ifdef ZTS static MUTEX_T php_io_terminal_resize_mutex = NULL; -#endif - -static void php_io_terminal_resize_lock(void) -{ -#ifdef ZTS - if (php_io_terminal_resize_mutex != NULL) { - tsrm_mutex_lock(php_io_terminal_resize_mutex); - } -#endif -} - -static void php_io_terminal_resize_unlock(void) -{ -#ifdef ZTS - if (php_io_terminal_resize_mutex != NULL) { - tsrm_mutex_unlock(php_io_terminal_resize_mutex); - } -#endif -} +# define PHP_IO_TERMINAL_RESIZE_LOCK() do { if (php_io_terminal_resize_mutex) tsrm_mutex_lock(php_io_terminal_resize_mutex); } while (0) +# define PHP_IO_TERMINAL_RESIZE_UNLOCK() do { if (php_io_terminal_resize_mutex) tsrm_mutex_unlock(php_io_terminal_resize_mutex); } while (0) +# else +# define PHP_IO_TERMINAL_RESIZE_LOCK() +# define PHP_IO_TERMINAL_RESIZE_UNLOCK() +# endif static zend_always_inline bool php_io_terminal_is_callable_handler(void (*handler)(int)) { @@ -211,7 +221,7 @@ static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation) bool installed = true; sig_atomic_t current_generation; - php_io_terminal_resize_lock(); + PHP_IO_TERMINAL_RESIZE_LOCK(); current_generation = php_io_terminal_resize_generation; if (php_io_terminal_resize_readers == 0) { @@ -228,20 +238,20 @@ static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation) *generation = current_generation; } - php_io_terminal_resize_unlock(); + PHP_IO_TERMINAL_RESIZE_UNLOCK(); return installed; } static void php_io_terminal_restore_resize_handler(void) { - php_io_terminal_resize_lock(); + PHP_IO_TERMINAL_RESIZE_LOCK(); if (php_io_terminal_resize_readers > 0 && --php_io_terminal_resize_readers == 0) { sigaction(SIGWINCH, &php_io_terminal_previous_resize_action, NULL); } - php_io_terminal_resize_unlock(); + PHP_IO_TERMINAL_RESIZE_UNLOCK(); } #endif /* SIGWINCH */ #endif /* !PHP_WIN32 */ @@ -284,20 +294,21 @@ static bool php_io_terminal_native_stream_is_tty(php_io_terminal_native_stream s static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity) { identity->stream = handle; + identity->is_console = false; if (!php_io_terminal_native_stream_is_valid(handle)) { return false; } DWORD mode; - return GetConsoleMode(handle, &mode); + if (!GetConsoleMode(handle, &mode)) { + return false; + } + identity->is_console = true; + return true; } static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) { - if (first->stream == second->stream) { - return true; - } - DWORD m1, m2; - return GetConsoleMode(first->stream, &m1) && GetConsoleMode(second->stream, &m2); + return first->is_console && second->is_console; } #else #ifndef PATH_MAX @@ -314,11 +325,7 @@ static bool php_io_terminal_get_slave_dev_from_pty_master(int fd, dev_t *slave_d got_slave_name = true; } #else -# if defined(ZTS) - if (php_io_terminal_ptsname_mutex != NULL) { - tsrm_mutex_lock(php_io_terminal_ptsname_mutex); - } -# endif + PHP_IO_TERMINAL_PTSNAME_LOCK(); const char *pts = ptsname(fd); if (pts != NULL) { size_t len = strlen(pts); @@ -327,11 +334,7 @@ static bool php_io_terminal_get_slave_dev_from_pty_master(int fd, dev_t *slave_d got_slave_name = true; } } -# if defined(ZTS) - if (php_io_terminal_ptsname_mutex != NULL) { - tsrm_mutex_unlock(php_io_terminal_ptsname_mutex); - } -# endif + PHP_IO_TERMINAL_PTSNAME_UNLOCK(); #endif if (got_slave_name && slave_path[0] != '\0') { @@ -394,10 +397,6 @@ static bool php_io_terminal_get_identity(php_io_terminal_native_stream fd, php_i static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) { - if (first->stream == second->stream) { - return true; - } - if (first->has_dev && second->has_dev) { return first->dev == second->dev; } @@ -496,9 +495,25 @@ static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mod } #ifdef PHP_WIN32 - return SetConsoleMode(shared->restore_stream, shared->saved_mode) != 0; + if (!SetConsoleMode(shared->restore_stream, shared->saved_mode)) { + return false; + } + DWORD actual_mode; + if (GetConsoleMode(shared->restore_stream, &actual_mode) && actual_mode != shared->saved_mode) { + return false; + } + return true; #else - return tcsetattr(shared->restore_stream, TCSANOW, &shared->saved_mode) == 0; + if (tcsetattr(shared->restore_stream, TCSANOW, &shared->saved_mode) != 0) { + return false; + } + struct termios actual_mode; + if (tcgetattr(shared->restore_stream, &actual_mode) == 0) { + if ((actual_mode.c_lflag & (ECHO | ICANON)) != (shared->saved_mode.c_lflag & (ECHO | ICANON))) { + return false; + } + } + return true; #endif } @@ -732,6 +747,11 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( { php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); if (shared != NULL) { + DWORD current_mode; + if (GetConsoleMode(handle, ¤t_mode) && (current_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0) { + DWORD raw = php_io_terminal_make_raw_mode(current_mode); + SetConsoleMode(handle, raw); + } shared->lease_count++; return shared; } @@ -752,6 +772,12 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( return NULL; } + DWORD actual_mode; + if (GetConsoleMode(handle, &actual_mode) && (actual_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + shared = emalloc(sizeof(*shared)); shared->identity = *identity; shared->restore_stream = restore_stream; @@ -1136,6 +1162,12 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( { php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity); if (shared != NULL) { + struct termios current_mode; + if (tcgetattr(fd, ¤t_mode) == 0 && (current_mode.c_lflag & (ECHO | ICANON)) != 0) { + struct termios raw = current_mode; + php_io_terminal_make_raw_mode(&raw); + tcsetattr(fd, TCSANOW, &raw); + } shared->lease_count++; return shared; } @@ -1158,6 +1190,12 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( return NULL; } + struct termios actual_mode; + if (tcgetattr(fd, &actual_mode) == 0 && (actual_mode.c_lflag & (ECHO | ICANON)) != 0) { + php_io_terminal_close_native_stream(restore_stream); + return NULL; + } + shared = emalloc(sizeof(*shared)); shared->identity = *identity; shared->restore_stream = restore_stream; @@ -1279,9 +1317,6 @@ static int php_io_terminal_read_byte( const sig_atomic_t *generation ) { - struct timespec remaining; - ssize_t bytes_read; - if (stream != NULL && stream->writepos > stream->readpos) { size_t read_bytes = php_stream_read(stream, (char *) byte, 1); if (read_bytes == 1) { @@ -1294,6 +1329,7 @@ static int php_io_terminal_read_byte( return PHP_IO_TERMINAL_READ_ERROR; } + struct timespec remaining; if (timeout != NULL) { remaining = *timeout; } @@ -1341,7 +1377,7 @@ static int php_io_terminal_read_byte( return PHP_IO_TERMINAL_READ_ERROR; } - bytes_read = read(fd, byte, 1); + ssize_t bytes_read = read(fd, byte, 1); if (bytes_read == 0) { return PHP_IO_TERMINAL_READ_EOF; } @@ -1362,10 +1398,30 @@ static zend_string *php_io_terminal_finish_utf8_sequence( ) { size_t initial_length = pending->length; + zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); + zend_hrtime_t deadline_ns = 0; + bool deadline_set = false; while (pending->length < pending->expected) { unsigned char key; - const struct timespec *timeout = pending->length == initial_length ? first_timeout : sequence_timeout; + struct timespec remaining_ts; + const struct timespec *timeout; + + if (pending->length == initial_length) { + timeout = first_timeout; + } else { + if (!deadline_set) { + deadline_ns = zend_hrtime() + seq_timeout_ns; + deadline_set = true; + } + zend_hrtime_t now = zend_hrtime(); + if (now >= deadline_ns) { + return NULL; + } + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + timeout = &remaining_ts; + } + int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); if (result == PHP_IO_TERMINAL_READ_TIMEOUT) { @@ -1425,11 +1481,19 @@ static zend_string *php_io_terminal_key_from_escape_sequence( { unsigned char seq[32]; size_t seq_len = 0; - int read_result; - seq[seq_len++] = 0x1b; - read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], sequence_timeout, false, NULL); + zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); + zend_hrtime_t deadline_ns = zend_hrtime() + seq_timeout_ns; + struct timespec remaining_ts; + + zend_hrtime_t now = zend_hrtime(); + if (now >= deadline_ns) { + return ZSTR_INIT_LITERAL("escape", false); + } + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + + int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], &remaining_ts, false, NULL); if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { return ZSTR_INIT_LITERAL("escape", false); } @@ -1445,9 +1509,14 @@ static zend_string *php_io_terminal_key_from_escape_sequence( if (seq[1] == '[') { while (seq_len < sizeof(seq)) { - unsigned char next; + now = zend_hrtime(); + if (now >= deadline_ns) { + break; + } + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); - read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, sequence_timeout, false, NULL); + unsigned char next; + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, &remaining_ts, false, NULL); if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { if (read_result == PHP_IO_TERMINAL_READ_EOF) { zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); @@ -1493,23 +1562,27 @@ static zend_string *php_io_terminal_key_from_escape_sequence( #undef PHP_IO_TERMINAL_CSI_IS } else if (seq[1] == 'O' && seq_len < sizeof(seq)) { - read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], sequence_timeout, false, NULL); - if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { - seq_len++; - switch (seq[2]) { - case 'P': return ZSTR_INIT_LITERAL("f1", false); - case 'Q': return ZSTR_INIT_LITERAL("f2", false); - case 'R': return ZSTR_INIT_LITERAL("f3", false); - case 'S': return ZSTR_INIT_LITERAL("f4", false); - case 'H': return ZSTR_INIT_LITERAL("home", false); - case 'F': return ZSTR_INIT_LITERAL("end", false); + now = zend_hrtime(); + if (now < deadline_ns) { + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], &remaining_ts, false, NULL); + if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + seq_len++; + switch (seq[2]) { + case 'P': return ZSTR_INIT_LITERAL("f1", false); + case 'Q': return ZSTR_INIT_LITERAL("f2", false); + case 'R': return ZSTR_INIT_LITERAL("f3", false); + case 'S': return ZSTR_INIT_LITERAL("f4", false); + case 'H': return ZSTR_INIT_LITERAL("home", false); + case 'F': return ZSTR_INIT_LITERAL("end", false); + } + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; } - } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { - zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); - return NULL; - } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { - zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); - return NULL; } } @@ -1550,36 +1623,33 @@ static zend_string *php_io_terminal_read_stream_key( ) { int fd = input; - struct termios mode; - struct termios raw_mode; - unsigned char key; - int read_result; - zend_string *result = NULL; - bool mode_changed; - sig_atomic_t resize_generation = 0; - bool resize_handler_installed; - php_poll_ctx *poll_ctx; + if (fd < 0 || isatty(fd) != 1) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); + return NULL; + } - if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + struct termios mode; + if (tcgetattr(fd, &mode) != 0) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to read key: input stream is not a terminal", 0); return NULL; } - poll_ctx = php_io_terminal_create_poll_context(fd); + php_poll_ctx *poll_ctx = php_io_terminal_create_poll_context(fd); if (poll_ctx == NULL) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to initialize terminal poll context", 0); return NULL; } + sig_atomic_t resize_generation = 0; #if defined(SIGWINCH) - resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); + bool resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); #else - resize_handler_installed = false; + bool resize_handler_installed = false; #endif - raw_mode = mode; + struct termios raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); - mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; + bool mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { #if defined(SIGWINCH) @@ -1592,10 +1662,13 @@ static zend_string *php_io_terminal_read_stream_key( return NULL; } + zend_string *result = NULL; + if (pending->length > 0) { result = php_io_terminal_finish_utf8_sequence(fd, stream, poll_ctx, pending, timeout, sequence_timeout); } else { - read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true, + unsigned char key; + int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true, resize_handler_installed ? &resize_generation : NULL); if (read_result == PHP_IO_TERMINAL_READ_RESIZE) { result = ZSTR_INIT_LITERAL("resize", false); @@ -1636,33 +1709,34 @@ static zend_string *php_io_terminal_read_stream_key( static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_stream input, php_stream *stream) { - const struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; int fd = input; - struct termios mode; - struct termios raw_mode; - smart_str secret = {0}; - bool success = false; - bool mode_changed; - php_poll_ctx *poll_ctx; + if (fd < 0 || isatty(fd) != 1) { + return NULL; + } - if (fd < 0 || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { + struct termios mode; + if (tcgetattr(fd, &mode) != 0) { return NULL; } - poll_ctx = php_io_terminal_create_poll_context(fd); + php_poll_ctx *poll_ctx = php_io_terminal_create_poll_context(fd); if (poll_ctx == NULL) { return NULL; } - raw_mode = mode; + struct termios raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); - mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; + bool mode_changed = memcmp(&raw_mode, &mode, sizeof(mode)) != 0; if (mode_changed && tcsetattr(fd, TCSANOW, &raw_mode) != 0) { php_poll_destroy(poll_ctx); return NULL; } + const struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; + smart_str secret = {0}; + bool success = false; + for (;;) { unsigned char key; int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, NULL, false, NULL); @@ -1685,13 +1759,44 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st goto restore; case 0x1b: { - zend_string *escape_key = php_io_terminal_key_from_escape_sequence(fd, stream, poll_ctx, &sequence_timeout); - bool is_escape = zend_string_equals_literal(escape_key, "escape"); - zend_string_release(escape_key); - if (is_escape) { + unsigned char next; + int read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, &sequence_timeout, false, NULL); + if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + /* Standalone Escape cancels */ goto restore; } - break; + if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { + /* Disconnect (EOF) or read error */ + goto restore; + } + + if (next == 0x03 || next == 0x04 || next == '\r' || next == '\n' || next == 0x1b) { + /* Escape followed immediately by control byte, Enter, or repeated Escape cancels */ + goto restore; + } + + if (next == '[') { + while (true) { + unsigned char csi_byte; + read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &csi_byte, &sequence_timeout, false, NULL); + if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { + break; + } + if (csi_byte >= 0x40 && csi_byte <= 0x7e) { + break; + } + } + break; + } + + if (next == 'O') { + unsigned char ss3_byte; + php_io_terminal_read_byte(fd, stream, poll_ctx, &ss3_byte, &sequence_timeout, false, NULL); + break; + } + + /* Any other trailing byte after escape: abort cleanly */ + goto restore; } default: { @@ -1706,7 +1811,7 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st seq[0] = key; for (i = 1; i < seq_len; i++) { - if (php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], &sequence_timeout, false, NULL) != 1) { + if (php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], NULL, false, NULL) != 1) { break; } if ((seq[i] & 0xc0) != 0x80) { @@ -1716,6 +1821,7 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st } } if (invalid_continuation) { + smart_str_appendl(&secret, (const char *) seq, i); key = invalid_byte; goto process_key; } @@ -1827,13 +1933,6 @@ static zend_object *php_io_terminal_key_enum_from_string(zend_string *key) return NULL; } -static void php_io_terminal_create_terminal_size(zval *return_value, zend_long cols, zend_long rows) -{ - object_init_ex(return_value, php_io_terminal_terminal_size_ce); - zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(return_value), "cols", sizeof("cols") - 1, cols); - zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(return_value), "rows", sizeof("rows") - 1, rows); -} - /* Io\Terminal\TerminalSize methods */ PHP_METHOD(Io_Terminal_TerminalSize, __construct) { @@ -1855,7 +1954,14 @@ PHP_METHOD(Io_Terminal_TerminalSize, __construct) } zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "cols", sizeof("cols") - 1, cols); + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } + zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "rows", sizeof("rows") - 1, rows); + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } } /* Io\Terminal\ModeToken methods */ @@ -1872,14 +1978,12 @@ PHP_METHOD(Io_Terminal_Terminal, __construct) PHP_METHOD(Io_Terminal_Terminal, fromStdio) { - php_io_terminal_object *intern; - php_io_terminal_stream_target target; - ZEND_PARSE_PARAMETERS_NONE(); object_init_ex(return_value, php_io_terminal_terminal_ce); - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); + php_io_terminal_stream_target target; if (php_io_terminal_stream_target_init(NULL, true, &target) && php_io_terminal_native_stream_is_valid(target.native_stream)) { intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); @@ -1890,8 +1994,6 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) { zval *input_arg; zval *output_arg = NULL; - php_io_terminal_object *intern; - php_io_terminal_stream_target target; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_RESOURCE(input_arg) @@ -1899,6 +2001,7 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) Z_PARAM_RESOURCE_OR_NULL(output_arg) ZEND_PARSE_PARAMETERS_END(); + php_io_terminal_stream_target target; if (!php_io_terminal_stream_target_init(input_arg, true, &target) || target.php_stream == NULL) { if (!EG(exception)) { zend_argument_value_error(1, "must be a valid stream resource"); @@ -1917,7 +2020,7 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) } object_init_ex(return_value, php_io_terminal_terminal_ce); - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); ZVAL_COPY(&intern->input_stream_val, input_arg); if (output_arg != NULL && !Z_ISNULL_P(output_arg)) { @@ -1933,19 +2036,18 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) PHP_METHOD(Io_Terminal_Terminal, getSize) { - php_io_terminal_object *intern; - php_io_terminal_stream_target stream; - zend_long columns = 0; - zend_long rows = 0; - ZEND_PARSE_PARAMETERS_NONE(); - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_stream_target stream; if (!php_io_terminal_stream_target_init(&intern->output_stream_val, false, &stream)) { RETURN_THROWS(); } + zend_long columns = 0; + zend_long rows = 0; + if (!php_io_terminal_stream_size(stream.native_stream, &columns, &rows)) { RETURN_FALSE; } @@ -1954,19 +2056,18 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) intern->last_rows = rows; intern->has_last_size = true; - php_io_terminal_create_terminal_size(return_value, columns, rows); + zval params[2]; + ZVAL_LONG(¶ms[0], columns); + ZVAL_LONG(¶ms[1], rows); + object_init_with_constructor(return_value, php_io_terminal_terminal_size_ce, 2, params, NULL); } PHP_METHOD(Io_Terminal_Terminal, enableRawMode) { - php_io_terminal_object *intern; - php_io_terminal_stream_target stream; - php_io_terminal_identity identity; - php_io_terminal_shared_mode *shared; - ZEND_PARSE_PARAMETERS_NONE(); - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_stream_target stream; if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); @@ -1977,6 +2078,7 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) RETURN_THROWS(); } + php_io_terminal_identity identity; if (!php_io_terminal_get_identity(stream.native_stream, &identity)) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); RETURN_THROWS(); @@ -1985,7 +2087,7 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) intern->identity = identity; intern->has_identity = true; - shared = php_io_terminal_acquire_raw_mode(stream.native_stream, &identity); + php_io_terminal_shared_mode *shared = php_io_terminal_acquire_raw_mode(stream.native_stream, &identity); if (shared == NULL) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0); RETURN_THROWS(); @@ -2003,14 +2105,13 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) PHP_METHOD(Io_Terminal_Terminal, restoreMode) { zval *mode_token = NULL; - php_io_terminal_object *intern; ZEND_PARSE_PARAMETERS_START(0, 1) Z_PARAM_OPTIONAL Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_ce) ZEND_PARSE_PARAMETERS_END(); - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token); @@ -2083,11 +2184,6 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) { php_date_time_duration *timeout_duration = NULL; php_date_time_duration *seq_timeout_duration = NULL; - php_io_terminal_object *intern; - php_io_terminal_stream_target stream; - php_io_terminal_stream_target out_stream; - zend_string *key; - zend_object *key_case; ZEND_PARSE_PARAMETERS_START(0, 2) Z_PARAM_OPTIONAL @@ -2105,7 +2201,8 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) RETURN_THROWS(); } - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_stream_target out_stream; if (php_io_terminal_stream_target_init(&intern->output_stream_val, false, &out_stream)) { zend_long cur_cols = 0, cur_rows = 0; @@ -2121,6 +2218,7 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) } } + php_io_terminal_stream_target stream; if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); } @@ -2141,7 +2239,7 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) } } - key = php_io_terminal_read_stream_key( + zend_string *key = php_io_terminal_read_stream_key( stream.native_stream, stream.php_stream, wait_ms, @@ -2157,36 +2255,20 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) if (timeout_duration != NULL) { is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); -#if SIZEOF_TIME_T < 8 - if (timeout_duration->duration.seconds > PHP_IO_TERMINAL_TIME_T_MAX) { - timeout.tv_sec = PHP_IO_TERMINAL_TIME_T_MAX; - } else { - timeout.tv_sec = (time_t) timeout_duration->duration.seconds; - } -#else - timeout.tv_sec = (time_t) timeout_duration->duration.seconds; -#endif + timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); timeout.tv_nsec = timeout_duration->duration.nanoseconds; timeout_ptr = &timeout; } if (seq_timeout_duration != NULL) { -#if SIZEOF_TIME_T < 8 - if (seq_timeout_duration->duration.seconds > PHP_IO_TERMINAL_TIME_T_MAX) { - sequence_timeout.tv_sec = PHP_IO_TERMINAL_TIME_T_MAX; - } else { - sequence_timeout.tv_sec = (time_t) seq_timeout_duration->duration.seconds; - } -#else - sequence_timeout.tv_sec = (time_t) seq_timeout_duration->duration.seconds; -#endif + sequence_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; } else if (is_non_blocking) { sequence_timeout.tv_sec = 0; sequence_timeout.tv_nsec = 0; } - key = php_io_terminal_read_stream_key( + zend_string *key = php_io_terminal_read_stream_key( stream.native_stream, stream.php_stream, timeout_ptr, @@ -2206,7 +2288,7 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) RETURN_FALSE; } - key_case = php_io_terminal_key_enum_from_string(key); + zend_object *key_case = php_io_terminal_key_enum_from_string(key); if (key_case != NULL) { if (key_case == zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize")) { php_io_terminal_stream_target out_s; @@ -2228,13 +2310,9 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) PHP_METHOD(Io_Terminal_Terminal, readSecret) { - php_io_terminal_object *intern; - php_io_terminal_stream_target stream; - zend_string *secret; - ZEND_PARSE_PARAMETERS_NONE(); - intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); #ifdef PHP_WIN32 memset(&intern->pending_key, 0, sizeof(intern->pending_key)); @@ -2244,25 +2322,28 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); #endif + php_io_terminal_stream_target stream; if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { RETURN_THROWS(); } #ifdef PHP_WIN32 - secret = php_io_terminal_read_stream_secret( + zend_string *secret = php_io_terminal_read_stream_secret( stream.native_stream, stream.php_stream, &intern->pending_key, &intern->pending_key_high_surrogate ); #else - secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream); + zend_string *secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream); #endif if (secret != NULL) { RETURN_STR(secret); } - zend_throw_exception(php_io_terminal_exception_ce, "Unable to read secret from terminal", 0); + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Unable to read secret from terminal", 0); + } RETURN_THROWS(); } @@ -2296,11 +2377,15 @@ PHP_MINIT_FUNCTION(terminal) php_io_terminal_object_handlers.free_obj = php_io_terminal_free_obj; php_io_terminal_object_handlers.clone_obj = NULL; -#if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) +#ifdef ZTS +# ifndef PHP_WIN32 +# ifdef SIGWINCH php_io_terminal_resize_mutex = tsrm_mutex_alloc(); -#endif -#if !defined(PHP_WIN32) && !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) +# endif +# ifndef PHP_IO_TERMINAL_HAVE_PTSNAME_R php_io_terminal_ptsname_mutex = tsrm_mutex_alloc(); +# endif +# endif #endif return SUCCESS; @@ -2308,17 +2393,21 @@ PHP_MINIT_FUNCTION(terminal) PHP_MSHUTDOWN_FUNCTION(terminal) { -#if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) +#ifdef ZTS +# ifndef PHP_WIN32 +# ifdef SIGWINCH if (php_io_terminal_resize_mutex != NULL) { tsrm_mutex_free(php_io_terminal_resize_mutex); php_io_terminal_resize_mutex = NULL; } -#endif -#if !defined(PHP_WIN32) && !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) +# endif +# ifndef PHP_IO_TERMINAL_HAVE_PTSNAME_R if (php_io_terminal_ptsname_mutex != NULL) { tsrm_mutex_free(php_io_terminal_ptsname_mutex); php_io_terminal_ptsname_mutex = NULL; } +# endif +# endif #endif return SUCCESS; diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index 819243bfc3c3..9ddf03b35ad9 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -58,6 +58,13 @@ foreach ([ } } +// Repeated TerminalSize construction must produce only the first error ($cols) +try { + $size->__construct(100, 50); +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + // TerminalSize serialization $serialized = serialize($size); $unserialized = unserialize($serialized); @@ -97,6 +104,7 @@ ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0 ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0 ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0 +Error: Cannot modify readonly property Io\Terminal\TerminalSize::$cols bool(true) int(80) int(24) diff --git a/ext/standard/tests/terminal/terminal_identity.phpt b/ext/standard/tests/terminal/terminal_identity.phpt index c4f650da30df..9f64ea9c9923 100644 --- a/ext/standard/tests/terminal/terminal_identity.phpt +++ b/ext/standard/tests/terminal/terminal_identity.phpt @@ -74,17 +74,43 @@ try { var_dump($t1->restoreMode($m1)); var_dump($t1->restoreMode()); +// Descriptor reuse: hold an active token, close the PTY, and open a new PTY that reuses the fd number +$m_reuse = $t1->enableRawMode(); fwrite($pipes1[0], "exit\n"); -fwrite($pipes2[0], "exit\n"); -unset($t1, $t1_dup, $t2); +unset($t1, $t1_dup); foreach ($pipes1 as $pipe) { if (is_resource($pipe)) fclose($pipe); } +proc_close($proc1); + +$proc3 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); +$t3 = Terminal::fromStreams($pipes3[0]); +try { + $t3->restoreMode($m_reuse); + echo "FAIL: reused fd accepted token\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes2[0], "exit\n"); +fwrite($pipes3[0], "exit\n"); +unset($t2, $t3); foreach ($pipes2 as $pipe) { if (is_resource($pipe)) fclose($pipe); } -proc_close($proc1); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} proc_close($proc2); +proc_close($proc3); ?> --EXPECT-- bool(true) @@ -92,3 +118,4 @@ bool(true) ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(false) +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_read_secret_control.phpt b/ext/standard/tests/terminal/terminal_read_secret_control.phpt index b2bce0ca8de8..7796c3b7c622 100644 --- a/ext/standard/tests/terminal/terminal_read_secret_control.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: readSecret cancels and throws on malformed UTF-8 with control characters +Io\Terminal\Terminal: readSecret cancels and throws on malformed UTF-8 with control characters, fragmented UTF-8, and escape aborts --SKIPIF-- ['pty'], 1 => ['pty'], 2 => ['pipe', 'w'], ], - $pipes, + $pipes1, ); -$terminal = Terminal::fromStreams($pipes[0]); +$terminal1 = Terminal::fromStreams($pipes1[0]); try { - $secret = $terminal->readSecret(); + $secret = $terminal1->readSecret(); echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } -unset($terminal); -foreach ($pipes as $pipe) { +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { if (is_resource($pipe)) fclose($pipe); } -proc_close($proc); +proc_close($proc1); + +// 2. Fragmented UTF-8: \xC3 followed 60ms later by \xA9\n must not drop \xC3 +$code2 = 'fwrite(STDOUT, "\xc3"); fflush(STDOUT); usleep(60000); fwrite(STDOUT, "\xa9\n"); fflush(STDOUT); fgets(STDIN);'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$terminal2 = Terminal::fromStreams($pipes2[0]); +$secret2 = $terminal2->readSecret(); +echo "Fragmented UTF-8: ", bin2hex($secret2), "\n"; + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// 3. Escape followed immediately by PTY disconnect +$code3 = 'fwrite(STDOUT, "\x1b"); fflush(STDOUT); usleep(10000); exit(0);'; +$proc3 = proc_open( + [PHP_BINARY, '-r', $code3], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +$terminal3 = Terminal::fromStreams($pipes3[0]); +try { + $terminal3->readSecret(); + echo "FAIL: expected exception on disconnect\n"; +} catch (TerminalException $e) { + echo "Disconnect: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// 4. Escape followed immediately by Ctrl+C (\x03) +$code4 = 'fwrite(STDOUT, "\x1b\x03"); fflush(STDOUT); fgets(STDIN);'; +$proc4 = proc_open( + [PHP_BINARY, '-r', $code4], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); + +$terminal4 = Terminal::fromStreams($pipes4[0]); +try { + $terminal4->readSecret(); + echo "FAIL: expected abort on escape+Ctrl+C\n"; +} catch (TerminalException $e) { + echo "Escape+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes4[0], "done\n"); +unset($terminal4); +foreach ($pipes4 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc4); + +// 5. Escape followed immediately by Enter (\r) +$code5 = 'fwrite(STDOUT, "\x1b\r"); fflush(STDOUT); fgets(STDIN);'; +$proc5 = proc_open( + [PHP_BINARY, '-r', $code5], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes5, +); + +$terminal5 = Terminal::fromStreams($pipes5[0]); +try { + $terminal5->readSecret(); + echo "FAIL: expected abort on escape+Enter\n"; +} catch (TerminalException $e) { + echo "Escape+Enter: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + +fwrite($pipes5[0], "done\n"); +unset($terminal5); +foreach ($pipes5 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc5); + ?> --EXPECT-- SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Fragmented UTF-8: c3a9 +Disconnect: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Escape+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Escape+Enter: caught Io\Terminal\TerminalException: Unable to read secret from terminal From 6435ed67211becac112a50e0f2c0c5e9464eaa37 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 06:21:44 +0545 Subject: [PATCH 26/38] Implement Io\Terminal mockable contracts, monotonic deadlines, and secret timeouts - Fix cancellation inside readSecret() escape parsing: - Abort on Ctrl+C, Ctrl+D, Escape, Enter inside CSI and SS3 sequences via terminal restoration path. - Distinguish cancellation, EOF, internal 25ms sequence timeout (which ends sequence skipping and continues secret input), and overall secret timeout. - Discard valid CSI (e.g. arrow keys) and SS3 (e.g. F1) sequences without exiting or emitting garbage. - Enforce one monotonic read deadline in readKey(): - Cap sequence timeout and UTF-8 continuation reads by remaining overall budget. - Zero timeout executes non-blocking read without blocking wait even if sequenceTimeout is specified. - Incomplete escape sequences return raw byte strings; incomplete UTF-8 sequences retain pending bytes across calls and return null on timeout. - Adopt null for absence: - Update Terminal::getSize() return type to ?TerminalSize. - Update Terminal::readKey() return type to Key|string|null. - Keep Terminal::restoreMode() return type as bool. - Add optional overall timeout to Terminal::readSecret(?Time\Duration $timeout = null): - Returns null on timeout, '' on empty submission (immediate Enter). - Throws TerminalException on cancellation/failure. - Throws ValueError on negative duration. - Introduce mockable boundaries: - Add Io\Terminal\TerminalInterface and Io\Terminal\ModeTokenInterface. - Make native Terminal implement TerminalInterface and ModeToken implement ModeTokenInterface while remaining final. - Have native restoreMode(?ModeTokenInterface $mode = null) validate native ModeToken, rejecting foreign tokens with ValueError. - Add comprehensive regression and acceptance tests. --- ...flectionExtension_getClassNames_basic.phpt | 2 + ext/standard/io_terminal.c | 380 +++++++++++++++--- ext/standard/io_terminal.stub.php | 30 +- ext/standard/io_terminal_arginfo.h | 71 +++- ext/standard/io_terminal_decl.h | 8 +- .../terminal/terminal_interface_mock.phpt | 92 +++++ .../terminal/terminal_read_key_contracts.phpt | 10 +- .../terminal_read_key_partial_escape.phpt | 106 +++++ .../terminal_read_key_utf8_retain.phpt | 103 +++++ .../tests/terminal/terminal_read_secret.phpt | 27 +- .../terminal_read_secret_control.phpt | 144 +++++++ .../terminal_read_secret_timeout.phpt | 165 ++++++++ .../tests/terminal/terminal_size.phpt | 4 +- 13 files changed, 1045 insertions(+), 97 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_interface_mock.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_secret_timeout.phpt diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index c0b95563215e..9caa099dd137 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -32,8 +32,10 @@ Io\Poll\PollException Io\Poll\Watcher Io\Terminal\Key Io\Terminal\ModeToken +Io\Terminal\ModeTokenInterface Io\Terminal\Terminal Io\Terminal\TerminalException +Io\Terminal\TerminalInterface Io\Terminal\TerminalSize RoundingMode SortDirection diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 5a22aaa79d34..2cd74f1111a0 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -145,6 +145,8 @@ typedef struct php_io_terminal_stream_target { static zend_class_entry *php_io_terminal_exception_ce; static zend_class_entry *php_io_terminal_key_ce; static zend_class_entry *php_io_terminal_terminal_size_ce; +static zend_class_entry *php_io_terminal_mode_token_interface_ce; +static zend_class_entry *php_io_terminal_terminal_interface_ce; static zend_class_entry *php_io_terminal_mode_token_ce; static zend_class_entry *php_io_terminal_terminal_ce; static zend_object_handlers php_io_terminal_mode_token_handlers; @@ -1043,9 +1045,13 @@ static zend_string *php_io_terminal_read_stream_secret( php_io_terminal_native_stream input, php_stream *stream, INPUT_RECORD *pending_key, - WCHAR *pending_key_high_surrogate + WCHAR *pending_key_high_surrogate, + DWORD wait_ms, + bool has_timeout, + bool *timed_out ) { + *timed_out = false; HANDLE handle = input; DWORD mode; @@ -1066,18 +1072,39 @@ static zend_string *php_io_terminal_read_stream_secret( bool success = false; bool failed = false; + zend_hrtime_t overall_deadline_ns = 0; + if (has_timeout) { + overall_deadline_ns = zend_hrtime() + ((zend_hrtime_t) wait_ms * 1000000ULL); + } + for (;;) { INPUT_RECORD record; KEY_EVENT_RECORD *key; WORD repeats; + DWORD current_wait = INFINITE; + + if (has_timeout) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + failed = true; + break; + } + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + uint64_t ms = (remaining_ns + 999999ULL) / 1000000ULL; + current_wait = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } if (!php_io_terminal_read_console_record( handle, - INFINITE, + current_wait, &record, &high_surrogate, pending_key, pending_key_high_surrogate)) { + if (has_timeout && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + } failed = true; break; } @@ -1394,7 +1421,9 @@ static zend_string *php_io_terminal_finish_utf8_sequence( php_poll_ctx *poll_ctx, php_io_terminal_utf8_pending *pending, const struct timespec *first_timeout, - const struct timespec *sequence_timeout + const struct timespec *sequence_timeout, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns ) { size_t initial_length = pending->length; @@ -1408,10 +1437,22 @@ static zend_string *php_io_terminal_finish_utf8_sequence( const struct timespec *timeout; if (pending->length == initial_length) { - timeout = first_timeout; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + return NULL; + } + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &remaining_ts); + timeout = &remaining_ts; + } else { + timeout = first_timeout; + } } else { if (!deadline_set) { deadline_ns = zend_hrtime() + seq_timeout_ns; + if (has_overall_deadline && deadline_ns > overall_deadline_ns) { + deadline_ns = overall_deadline_ns; + } deadline_set = true; } zend_hrtime_t now = zend_hrtime(); @@ -1449,48 +1490,31 @@ static zend_string *php_io_terminal_finish_utf8_sequence( return result; } -static zend_string *php_io_terminal_key_from_utf8_sequence( - int fd, - php_stream *stream, - php_poll_ctx *poll_ctx, - unsigned char key, - const struct timespec *sequence_timeout, - php_io_terminal_utf8_pending *pending -) -{ - size_t sequence_len = php_io_terminal_utf8_sequence_len(key); - - if (sequence_len == 1) { - return ZSTR_CHAR(key); - } - - memset(pending, 0, sizeof(*pending)); - pending->bytes[0] = key; - pending->length = 1; - pending->expected = sequence_len; - - return php_io_terminal_finish_utf8_sequence(fd, stream, poll_ctx, pending, sequence_timeout, sequence_timeout); -} - static zend_string *php_io_terminal_key_from_escape_sequence( int fd, php_stream *stream, php_poll_ctx *poll_ctx, - const struct timespec *sequence_timeout + const struct timespec *sequence_timeout, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns ) { unsigned char seq[32]; size_t seq_len = 0; seq[seq_len++] = 0x1b; + zend_hrtime_t now = zend_hrtime(); zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); - zend_hrtime_t deadline_ns = zend_hrtime() + seq_timeout_ns; - struct timespec remaining_ts; + zend_hrtime_t deadline_ns = now + seq_timeout_ns; + if (has_overall_deadline && deadline_ns > overall_deadline_ns) { + deadline_ns = overall_deadline_ns; + } - zend_hrtime_t now = zend_hrtime(); if (now >= deadline_ns) { return ZSTR_INIT_LITERAL("escape", false); } + + struct timespec remaining_ts; php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], &remaining_ts, false, NULL); @@ -1589,13 +1613,43 @@ static zend_string *php_io_terminal_key_from_escape_sequence( return zend_string_init((const char *) seq, seq_len, false); } +static zend_string *php_io_terminal_key_from_utf8_sequence( + int fd, + php_stream *stream, + php_poll_ctx *poll_ctx, + unsigned char key, + const struct timespec *sequence_timeout, + php_io_terminal_utf8_pending *pending, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns +) +{ + size_t sequence_len = php_io_terminal_utf8_sequence_len(key); + + if (sequence_len == 1) { + return ZSTR_CHAR(key); + } + + memset(pending, 0, sizeof(*pending)); + pending->bytes[0] = key; + pending->length = 1; + pending->expected = sequence_len; + + return php_io_terminal_finish_utf8_sequence( + fd, stream, poll_ctx, pending, sequence_timeout, sequence_timeout, + has_overall_deadline, overall_deadline_ns + ); +} + static zend_string *php_io_terminal_key_from_byte( int fd, php_stream *stream, php_poll_ctx *poll_ctx, unsigned char key, const struct timespec *sequence_timeout, - php_io_terminal_utf8_pending *pending + php_io_terminal_utf8_pending *pending, + bool has_overall_deadline, + zend_hrtime_t overall_deadline_ns ) { switch (key) { @@ -1608,9 +1662,13 @@ static zend_string *php_io_terminal_key_from_byte( case '\b': return ZSTR_INIT_LITERAL("backspace", false); case 0x1b: - return php_io_terminal_key_from_escape_sequence(fd, stream, poll_ctx, sequence_timeout); + return php_io_terminal_key_from_escape_sequence( + fd, stream, poll_ctx, sequence_timeout, has_overall_deadline, overall_deadline_ns + ); default: - return php_io_terminal_key_from_utf8_sequence(fd, stream, poll_ctx, key, sequence_timeout, pending); + return php_io_terminal_key_from_utf8_sequence( + fd, stream, poll_ctx, key, sequence_timeout, pending, has_overall_deadline, overall_deadline_ns + ); } } @@ -1662,10 +1720,19 @@ static zend_string *php_io_terminal_read_stream_key( return NULL; } + bool has_overall_deadline = (timeout != NULL); + zend_hrtime_t overall_deadline_ns = 0; + if (has_overall_deadline) { + overall_deadline_ns = zend_hrtime() + php_io_terminal_timespec_to_ns(timeout); + } + zend_string *result = NULL; if (pending->length > 0) { - result = php_io_terminal_finish_utf8_sequence(fd, stream, poll_ctx, pending, timeout, sequence_timeout); + result = php_io_terminal_finish_utf8_sequence( + fd, stream, poll_ctx, pending, timeout, sequence_timeout, + has_overall_deadline, overall_deadline_ns + ); } else { unsigned char key; int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true, @@ -1673,7 +1740,10 @@ static zend_string *php_io_terminal_read_stream_key( if (read_result == PHP_IO_TERMINAL_READ_RESIZE) { result = ZSTR_INIT_LITERAL("resize", false); } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { - result = php_io_terminal_key_from_byte(fd, stream, poll_ctx, key, sequence_timeout, pending); + result = php_io_terminal_key_from_byte( + fd, stream, poll_ctx, key, sequence_timeout, pending, + has_overall_deadline, overall_deadline_ns + ); } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) { @@ -1707,8 +1777,14 @@ static zend_string *php_io_terminal_read_stream_key( return result; } -static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_stream input, php_stream *stream) +static zend_string *php_io_terminal_read_stream_secret( + php_io_terminal_native_stream input, + php_stream *stream, + const struct timespec *timeout, + bool *timed_out +) { + *timed_out = false; int fd = input; if (fd < 0 || isatty(fd) != 1) { return NULL; @@ -1733,15 +1809,38 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st return NULL; } - const struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; + bool has_overall_deadline = (timeout != NULL); + zend_hrtime_t overall_deadline_ns = 0; + if (has_overall_deadline) { + overall_deadline_ns = zend_hrtime() + php_io_terminal_timespec_to_ns(timeout); + } + smart_str secret = {0}; bool success = false; - for (;;) { + while (true) { unsigned char key; - int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, NULL, false, NULL); - if (result != 1) { - break; + struct timespec current_timeout; + const struct timespec *current_timeout_ptr = NULL; + + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout); + current_timeout_ptr = ¤t_timeout; + } + + int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, current_timeout_ptr, false, NULL); + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + *timed_out = true; + goto restore; + } + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + *timed_out = false; + goto restore; } process_key: @@ -1751,36 +1850,93 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st success = true; goto restore; case 0x7f: - case '\b': + case 0x08: php_io_terminal_buffer_remove_last_utf8_char(&secret); break; case 0x03: case 0x04: + *timed_out = false; goto restore; case 0x1b: { + struct timespec step_timeout; + const struct timespec *step_timeout_ptr = NULL; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); + step_timeout_ptr = &step_timeout; + } else { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 25000000L; + step_timeout_ptr = &step_timeout; + } + unsigned char next; - int read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, &sequence_timeout, false, NULL); + int read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, step_timeout_ptr, false, NULL); if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } /* Standalone Escape cancels */ + *timed_out = false; goto restore; } if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { /* Disconnect (EOF) or read error */ + *timed_out = false; goto restore; } - if (next == 0x03 || next == 0x04 || next == '\r' || next == '\n' || next == 0x1b) { + if (next == 0x03 || next == 0x04 || next == 0x1b || next == '\r' || next == '\n') { /* Escape followed immediately by control byte, Enter, or repeated Escape cancels */ + *timed_out = false; goto restore; } if (next == '[') { while (true) { + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); + step_timeout_ptr = &step_timeout; + } else { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 25000000L; + step_timeout_ptr = &step_timeout; + } + unsigned char csi_byte; - read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &csi_byte, &sequence_timeout, false, NULL); + read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &csi_byte, step_timeout_ptr, false, NULL); if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { - break; + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + /* Internal sequence timeout: end sequence skipping and continue secret input! */ + break; + } + /* Disconnect (EOF) or read error */ + *timed_out = false; + goto restore; + } + if (csi_byte == 0x03 || csi_byte == 0x04 || csi_byte == 0x1b || csi_byte == '\r' || csi_byte == '\n') { + /* Cancellation byte inside CSI! */ + *timed_out = false; + goto restore; } if (csi_byte >= 0x40 && csi_byte <= 0x7e) { break; @@ -1790,12 +1946,47 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st } if (next == 'O') { + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); + step_timeout_ptr = &step_timeout; + } else { + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 25000000L; + step_timeout_ptr = &step_timeout; + } + unsigned char ss3_byte; - php_io_terminal_read_byte(fd, stream, poll_ctx, &ss3_byte, &sequence_timeout, false, NULL); + read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &ss3_byte, step_timeout_ptr, false, NULL); + if (read_res != PHP_IO_TERMINAL_READ_SUCCESS) { + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { + /* Internal sequence timeout: continue secret input */ + break; + } + *timed_out = false; + goto restore; + } + if (ss3_byte == 0x03 || ss3_byte == 0x04 || ss3_byte == 0x1b || ss3_byte == '\r' || ss3_byte == '\n') { + /* Cancellation byte inside SS3! */ + *timed_out = false; + goto restore; + } + /* Valid SS3 key (like F1-F4): safely ignored */ break; } /* Any other trailing byte after escape: abort cleanly */ + *timed_out = false; goto restore; } default: @@ -1811,8 +2002,26 @@ static zend_string *php_io_terminal_read_stream_secret(php_io_terminal_native_st seq[0] = key; for (i = 1; i < seq_len; i++) { - if (php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], NULL, false, NULL) != 1) { - break; + struct timespec cont_timeout; + const struct timespec *cont_timeout_ptr = NULL; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cont_timeout); + cont_timeout_ptr = &cont_timeout; + } + + int cont_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], cont_timeout_ptr, false, NULL); + if (cont_res != PHP_IO_TERMINAL_READ_SUCCESS) { + if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + goto restore; + } + *timed_out = false; + goto restore; } if ((seq[i] & 0xc0) != 0x80) { invalid_continuation = true; @@ -2049,7 +2258,7 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) zend_long rows = 0; if (!php_io_terminal_stream_size(stream.native_stream, &columns, &rows)) { - RETURN_FALSE; + RETURN_NULL(); } intern->last_cols = columns; @@ -2108,12 +2317,16 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) ZEND_PARSE_PARAMETERS_START(0, 1) Z_PARAM_OPTIONAL - Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_ce) + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_interface_ce) ZEND_PARSE_PARAMETERS_END(); php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { + if (!instanceof_function(Z_OBJCE_P(mode_token), php_io_terminal_mode_token_ce)) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token); if (!mode->valid || mode->shared == NULL) { @@ -2260,12 +2473,12 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) timeout_ptr = &timeout; } - if (seq_timeout_duration != NULL) { - sequence_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); - sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; - } else if (is_non_blocking) { + if (is_non_blocking) { sequence_timeout.tv_sec = 0; sequence_timeout.tv_nsec = 0; + } else if (seq_timeout_duration != NULL) { + sequence_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); + sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; } zend_string *key = php_io_terminal_read_stream_key( @@ -2285,7 +2498,7 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) } if (key == NULL) { - RETURN_FALSE; + RETURN_NULL(); } zend_object *key_case = php_io_terminal_key_enum_from_string(key); @@ -2310,7 +2523,17 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) PHP_METHOD(Io_Terminal_Terminal, readSecret) { - ZEND_PARSE_PARAMETERS_NONE(); + php_date_time_duration *timeout_duration = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_DATE_TIME_DURATION_OR_NULL(timeout_duration) + ZEND_PARSE_PARAMETERS_END(); + + if (timeout_duration != NULL && timeout_duration->duration.negative) { + zend_argument_value_error(1, "must not be negative"); + RETURN_THROWS(); + } php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); @@ -2327,20 +2550,50 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) RETURN_THROWS(); } + bool timed_out = false; #ifdef PHP_WIN32 + DWORD wait_ms = INFINITE; + bool has_timeout = false; + if (timeout_duration != NULL) { + has_timeout = true; + uint64_t sec = timeout_duration->duration.seconds; + uint32_t nsec = timeout_duration->duration.nanoseconds; + if (sec == 0 && nsec == 0) { + wait_ms = 0; + } else if (sec >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS / 1000) { + wait_ms = PHP_IO_TERMINAL_MAX_WAIT_MS; + } else { + uint64_t ms = sec * 1000 + (nsec + 999999) / 1000000; + wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } + } zend_string *secret = php_io_terminal_read_stream_secret( stream.native_stream, stream.php_stream, &intern->pending_key, - &intern->pending_key_high_surrogate + &intern->pending_key_high_surrogate, + wait_ms, + has_timeout, + &timed_out ); #else - zend_string *secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream); + struct timespec timeout; + const struct timespec *timeout_ptr = NULL; + if (timeout_duration != NULL) { + timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + timeout.tv_nsec = timeout_duration->duration.nanoseconds; + timeout_ptr = &timeout; + } + zend_string *secret = php_io_terminal_read_stream_secret(stream.native_stream, stream.php_stream, timeout_ptr, &timed_out); #endif if (secret != NULL) { RETURN_STR(secret); } + if (timed_out) { + RETURN_NULL(); + } + if (!EG(exception)) { zend_throw_exception(php_io_terminal_exception_ce, "Unable to read secret from terminal", 0); } @@ -2363,14 +2616,17 @@ PHP_MINIT_FUNCTION(terminal) php_io_terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); - php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); + php_io_terminal_mode_token_interface_ce = register_class_Io_Terminal_ModeTokenInterface(); + php_io_terminal_terminal_interface_ce = register_class_Io_Terminal_TerminalInterface(); + + php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(php_io_terminal_mode_token_interface_ce); php_io_terminal_mode_token_ce->create_object = php_io_terminal_mode_token_create_object; memcpy(&php_io_terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_mode_token_handlers)); php_io_terminal_mode_token_handlers.offset = offsetof(php_io_terminal_mode_token_object, std); php_io_terminal_mode_token_handlers.free_obj = php_io_terminal_mode_token_free_obj; php_io_terminal_mode_token_handlers.clone_obj = NULL; - php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(); + php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(php_io_terminal_terminal_interface_ce); php_io_terminal_terminal_ce->create_object = php_io_terminal_create_object; memcpy(&php_io_terminal_object_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_object_handlers)); php_io_terminal_object_handlers.offset = offsetof(php_io_terminal_object, std); diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index 22ed5c5b1497..e85a24577563 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -49,11 +49,29 @@ enum Key public function __construct(int $cols, int $rows) {} } + interface ModeTokenInterface {} + + interface TerminalInterface + { + public function getSize(): ?TerminalSize; + + public function enableRawMode(): ModeTokenInterface; + + public function restoreMode(?ModeTokenInterface $mode = null): bool; + + public function readKey( + ?\Time\Duration $timeout = null, + ?\Time\Duration $sequenceTimeout = null, + ): Key|string|null; + + public function readSecret(?\Time\Duration $timeout = null): ?string; + } + /** * @strict-properties * @not-serializable */ - final class ModeToken + final class ModeToken implements ModeTokenInterface { private function __construct() {} } @@ -62,7 +80,7 @@ private function __construct() {} * @strict-properties * @not-serializable */ - final class Terminal + final class Terminal implements TerminalInterface { private function __construct() {} @@ -74,17 +92,17 @@ public static function fromStdio(): Terminal {} */ public static function fromStreams($input, $output = null): Terminal {} - public function getSize(): TerminalSize|false {} + public function getSize(): ?TerminalSize {} public function enableRawMode(): ModeToken {} - public function restoreMode(?ModeToken $mode = null): bool {} + public function restoreMode(?ModeTokenInterface $mode = null): bool {} public function readKey( ?\Time\Duration $timeout = null, ?\Time\Duration $sequenceTimeout = null, - ): Key|string|false {} + ): Key|string|null {} - public function readSecret(): string {} + public function readSecret(?\Time\Duration $timeout = null): ?string {} } } diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 0965bea988d7..47801aa1f343 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 919125ad09816c09ee488556d37f36f42818be80 + * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b * Has decl header: yes */ #include "zend_enum.h" @@ -9,6 +9,25 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_TerminalSize___construct, 0, 0, ZEND_ARG_TYPE_INFO(0, rows, IS_LONG, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_getSize, 0, 0, Io\\Terminal\\TerminalSize, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_enableRawMode, 0, 0, Io\\Terminal\\ModeTokenInterface, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_restoreMode, 0, 0, _IS_BOOL, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, mode, Io\\Terminal\\ModeTokenInterface, 1, "null") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_TerminalInterface_readKey, 0, 0, Io\\Terminal\\Key, MAY_BE_STRING|MAY_BE_NULL) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, timeout, Time\\Duration, 1, "null") + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, sequenceTimeout, Time\\Duration, 1, "null") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_readSecret, 0, 0, IS_STRING, 1) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, timeout, Time\\Duration, 1, "null") +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_ModeToken___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -22,23 +41,16 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_fromSt ZEND_ARG_INFO_WITH_DEFAULT_VALUE(0, output, "null") ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_Terminal_getSize, 0, 0, Io\\Terminal\\TerminalSize, MAY_BE_FALSE) -ZEND_END_ARG_INFO() +#define arginfo_class_Io_Terminal_Terminal_getSize arginfo_class_Io_Terminal_TerminalInterface_getSize ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_enableRawMode, 0, 0, Io\\Terminal\\ModeToken, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_restoreMode, 0, 0, _IS_BOOL, 0) - ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, mode, Io\\Terminal\\ModeToken, 1, "null") -ZEND_END_ARG_INFO() +#define arginfo_class_Io_Terminal_Terminal_restoreMode arginfo_class_Io_Terminal_TerminalInterface_restoreMode -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_Terminal_readKey, 0, 0, Io\\Terminal\\Key, MAY_BE_STRING|MAY_BE_FALSE) - ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, timeout, Time\\Duration, 1, "null") - ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, sequenceTimeout, Time\\Duration, 1, "null") -ZEND_END_ARG_INFO() +#define arginfo_class_Io_Terminal_Terminal_readKey arginfo_class_Io_Terminal_TerminalInterface_readKey -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_readSecret, 0, 0, IS_STRING, 0) -ZEND_END_ARG_INFO() +#define arginfo_class_Io_Terminal_Terminal_readSecret arginfo_class_Io_Terminal_TerminalInterface_readSecret ZEND_METHOD(Io_Terminal_TerminalSize, __construct); ZEND_METHOD(Io_Terminal_ModeToken, __construct); @@ -56,6 +68,15 @@ static const zend_function_entry class_Io_Terminal_TerminalSize_methods[] = { ZEND_FE_END }; +static const zend_function_entry class_Io_Terminal_TerminalInterface_methods[] = { + ZEND_RAW_FENTRY("getSize", NULL, arginfo_class_Io_Terminal_TerminalInterface_getSize, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("enableRawMode", NULL, arginfo_class_Io_Terminal_TerminalInterface_enableRawMode, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("restoreMode", NULL, arginfo_class_Io_Terminal_TerminalInterface_restoreMode, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("readKey", NULL, arginfo_class_Io_Terminal_TerminalInterface_readKey, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("readSecret", NULL, arginfo_class_Io_Terminal_TerminalInterface_readSecret, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_FE_END +}; + static const zend_function_entry class_Io_Terminal_ModeToken_methods[] = { ZEND_ME(Io_Terminal_ModeToken, __construct, arginfo_class_Io_Terminal_ModeToken___construct, ZEND_ACC_PRIVATE) ZEND_FE_END @@ -164,22 +185,44 @@ static zend_class_entry *register_class_Io_Terminal_TerminalSize(void) return class_entry; } -static zend_class_entry *register_class_Io_Terminal_ModeToken(void) +static zend_class_entry *register_class_Io_Terminal_ModeTokenInterface(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeTokenInterface", NULL); + class_entry = zend_register_internal_interface(&ce); + + return class_entry; +} + +static zend_class_entry *register_class_Io_Terminal_TerminalInterface(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "TerminalInterface", class_Io_Terminal_TerminalInterface_methods); + class_entry = zend_register_internal_interface(&ce); + + return class_entry; +} + +static zend_class_entry *register_class_Io_Terminal_ModeToken(zend_class_entry *class_entry_Io_Terminal_ModeTokenInterface) { zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeToken", class_Io_Terminal_ModeToken_methods); class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); + zend_class_implements(class_entry, 1, class_entry_Io_Terminal_ModeTokenInterface); return class_entry; } -static zend_class_entry *register_class_Io_Terminal_Terminal(void) +static zend_class_entry *register_class_Io_Terminal_Terminal(zend_class_entry *class_entry_Io_Terminal_TerminalInterface) { zend_class_entry ce, *class_entry; INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "Terminal", class_Io_Terminal_Terminal_methods); class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); + zend_class_implements(class_entry, 1, class_entry_Io_Terminal_TerminalInterface); return class_entry; } diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index a377005dd97a..c864f22b91b5 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 919125ad09816c09ee488556d37f36f42818be80 */ + * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b */ -#ifndef ZEND_IO_TERMINAL_DECL_919125ad09816c09ee488556d37f36f42818be80_H -#define ZEND_IO_TERMINAL_DECL_919125ad09816c09ee488556d37f36f42818be80_H +#ifndef ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H +#define ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_919125ad09816c09ee488556d37f36f42818be80_H */ +#endif /* ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H */ diff --git a/ext/standard/tests/terminal/terminal_interface_mock.phpt b/ext/standard/tests/terminal/terminal_interface_mock.phpt new file mode 100644 index 000000000000..d6dd44f0a738 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt @@ -0,0 +1,92 @@ +--TEST-- +Io\Terminal\TerminalInterface: mockable terminal boundary and foreign token rejection +--FILE-- +implementsInterface(TerminalInterface::class)); + +$rcToken = new ReflectionClass(ModeToken::class); +var_dump($rcToken->implementsInterface(ModeTokenInterface::class)); + +// Application-level fake +class FakeModeToken implements ModeTokenInterface {} + +class FakeTerminal implements TerminalInterface +{ + public bool $isRaw = false; + + public function getSize(): ?TerminalSize + { + return new TerminalSize(120, 40); + } + + public function enableRawMode(): ModeTokenInterface + { + $this->isRaw = true; + return new FakeModeToken(); + } + + public function restoreMode(?ModeTokenInterface $mode = null): bool + { + $this->isRaw = false; + return true; + } + + public function readKey( + ?Duration $timeout = null, + ?Duration $sequenceTimeout = null, + ): Key|string|null { + return Key::Up; + } + + public function readSecret(?Duration $timeout = null): ?string + { + return "mocked-secret"; + } +} + +// Application service consuming TerminalInterface +function promptPassword(TerminalInterface $term): string +{ + $size = $term->getSize(); + $token = $term->enableRawMode(); + try { + $secret = $term->readSecret(Duration::fromSeconds(5)); + return sprintf("size=%dx%d secret=%s", $size->cols, $size->rows, $secret ?? 'none'); + } finally { + $term->restoreMode($token); + } +} + +$fake = new FakeTerminal(); +echo promptPassword($fake), PHP_EOL; +var_dump($fake->isRaw); + +// Native restoreMode rejects foreign ModeTokenInterface implementations +$fp = fopen('php://temp', 'r+'); +$native = Terminal::fromStreams($fp); + +try { + $native->restoreMode(new FakeModeToken()); + echo "FAIL: native accepted foreign ModeTokenInterface\n"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +bool(true) +bool(true) +size=120x40 secret=mocked-secret +bool(false) +Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt index ba3f63d0debb..4d9e5e27999f 100644 --- a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: readKey false vs TerminalException contracts on POSIX PTY +Io\Terminal\Terminal: readKey null vs TerminalException contracts on POSIX PTY --SKIPIF-- enableRawMode(); -// Contract 1: No input + zero timeout (non-blocking) returns false immediately +// Contract 1: No input + zero timeout (non-blocking) returns null immediately $start = hrtime(true); $key = $terminal->readKey(Duration::fromSeconds(0)); $elapsed_ms = (hrtime(true) - $start) / 1e6; -var_dump($key === false); +var_dump($key === null); var_dump($elapsed_ms < 20.0); -// Contract 2: No input + small finite timeout returns false after duration +// Contract 2: No input + small finite timeout returns null after duration $start = hrtime(true); $key = $terminal->readKey(Duration::fromMilliseconds(25)); $elapsed_ms = (hrtime(true) - $start) / 1e6; -var_dump($key === false); +var_dump($key === null); var_dump($elapsed_ms >= 20.0); // Contract 3: Negative Duration throws ValueError diff --git a/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt new file mode 100644 index 000000000000..11965e262e56 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt @@ -0,0 +1,106 @@ +--TEST-- +Io\Terminal\Terminal: readKey partial escape sequence and overall deadline timing +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$terminal1 = Terminal::fromStreams($pipes1[0]); +$terminal1->enableRawMode(); + +$k1 = $terminal1->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(100)); +$k2 = $terminal1->readKey(Duration::fromMilliseconds(100)); + +echo "Case 1 k1 is string: ", var_export(is_string($k1), true), PHP_EOL; +echo "Case 1 k1 hex: ", bin2hex($k1), PHP_EOL; +echo "Case 1 k2: ", var_export($k2, true), PHP_EOL; + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// Case 2: Overall deadline 30ms, ESC sent at 0ms, [ arrives at 80ms. +// Because [ does not arrive before the 30ms deadline, the first call returns Key::Escape. +// The subsequent call reads [ independently. +$code2 = 'fwrite(STDOUT, "\x1b"); fflush(STDOUT); usleep(80000); fwrite(STDOUT, "["); fflush(STDOUT); fgets(STDIN);'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2->enableRawMode(); + +$k3 = $terminal2->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(100)); +$k4 = $terminal2->readKey(Duration::fromMilliseconds(100)); + +echo "Case 2 k3 is Escape: ", var_export($k3 === Key::Escape, true), PHP_EOL; +echo "Case 2 k4: ", var_export($k4, true), PHP_EOL; + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +?> +--EXPECT-- +Case 1 k1 is string: true +Case 1 k1 hex: 1b5b +Case 1 k2: 'A' +Case 2 k3 is Escape: true +Case 2 k4: '[' diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt new file mode 100644 index 000000000000..31d3483a2659 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt @@ -0,0 +1,103 @@ +--TEST-- +Io\Terminal\Terminal: readKey retains fragmented UTF-8 across calls on timeout +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +$terminal1 = Terminal::fromStreams($pipes1[0]); +$terminal1->enableRawMode(); + +$k1 = $terminal1->readKey(Duration::fromMilliseconds(25)); +$k2 = $terminal1->readKey(Duration::fromMilliseconds(100)); + +echo "k1 is null: ", var_export($k1 === null, true), PHP_EOL; +echo "k2 is euro: ", var_export($k2 === "€", true), PHP_EOL; +echo "k2 hex: ", bin2hex($k2), PHP_EOL; + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// 2. 4-byte UTF-8 emoji (😀: \xf0\x9f\x98\x80) split across timeout +$code2 = 'fwrite(STDOUT, "\xf0\x9f"); fflush(STDOUT); usleep(60000); fwrite(STDOUT, "\x98\x80"); fflush(STDOUT); fgets(STDIN);'; +$proc2 = proc_open( + [PHP_BINARY, '-r', $code2], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +$terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2->enableRawMode(); + +$k3 = $terminal2->readKey(Duration::fromMilliseconds(25)); +$k4 = $terminal2->readKey(Duration::fromMilliseconds(100)); + +echo "k3 is null: ", var_export($k3 === null, true), PHP_EOL; +echo "k4 is emoji: ", var_export($k4 === "😀", true), PHP_EOL; +echo "k4 hex: ", bin2hex($k4), PHP_EOL; + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +?> +--EXPECT-- +k1 is null: true +k2 is euro: true +k2 hex: e282ac +k3 is null: true +k4 is emoji: true +k4 hex: f09f9880 diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt index b460402e9829..c73ab05d7040 100644 --- a/ext/standard/tests/terminal/terminal_read_secret.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -1,10 +1,11 @@ --TEST-- -Io\Terminal\Terminal: readSecret parameter count and non-tty failure handling +Io\Terminal\Terminal: readSecret parameter validation and non-tty failure handling --FILE-- getMessage(), PHP_EOL; } -// readSecret takes 0 arguments +// readSecret rejects invalid argument type try { $nonTty->readSecret('prompt'); - echo "FAIL: readSecret accepted arguments\n"; + echo "FAIL: readSecret accepted string argument\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// readSecret rejects extra arguments +try { + $nonTty->readSecret(null, null); + echo "FAIL: readSecret accepted multiple arguments\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// readSecret rejects negative timeout +try { + $nonTty->readSecret(Duration::fromSeconds(1)->negate()); + echo "FAIL: readSecret accepted negative timeout\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -31,4 +48,6 @@ try { --EXPECTF-- bool(true) Io\Terminal\TerminalException: Unable to read secret from terminal -ArgumentCountError: Io\Terminal\Terminal::readSecret() expects exactly 0 arguments, 1 given +TypeError: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must be of type ?Time\Duration, string given +ArgumentCountError: Io\Terminal\Terminal::readSecret() expects at most 1 argument, 2 given +ValueError: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must not be negative diff --git a/ext/standard/tests/terminal/terminal_read_secret_control.phpt b/ext/standard/tests/terminal/terminal_read_secret_control.phpt index 7796c3b7c622..b06706f11510 100644 --- a/ext/standard/tests/terminal/terminal_read_secret_control.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt @@ -166,6 +166,144 @@ foreach ($pipes5 as $pipe) { } proc_close($proc5); +// 6. CSI followed by Ctrl+C (\x1b[\x03) cancels +$code6 = 'fwrite(STDOUT, "\x1b[\x03"); fflush(STDOUT); fgets(STDIN);'; +$proc6 = proc_open( + [PHP_BINARY, '-r', $code6], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes6, +); +$terminal6 = Terminal::fromStreams($pipes6[0]); +try { + $terminal6->readSecret(); + echo "FAIL: expected abort on CSI+Ctrl+C\n"; +} catch (TerminalException $e) { + echo "CSI+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} +fwrite($pipes6[0], "done\n"); +unset($terminal6); +foreach ($pipes6 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc6); + +// 7. CSI followed by Ctrl+D (\x1b[\x04) cancels +$code7 = 'fwrite(STDOUT, "\x1b[\x04"); fflush(STDOUT); fgets(STDIN);'; +$proc7 = proc_open( + [PHP_BINARY, '-r', $code7], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes7, +); +$terminal7 = Terminal::fromStreams($pipes7[0]); +try { + $terminal7->readSecret(); + echo "FAIL: expected abort on CSI+Ctrl+D\n"; +} catch (TerminalException $e) { + echo "CSI+Ctrl+D: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} +fwrite($pipes7[0], "done\n"); +unset($terminal7); +foreach ($pipes7 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc7); + +// 8. SS3 followed by Ctrl+C (\x1bO\x03) cancels +$code8 = 'fwrite(STDOUT, "\x1bO\x03"); fflush(STDOUT); fgets(STDIN);'; +$proc8 = proc_open( + [PHP_BINARY, '-r', $code8], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes8, +); +$terminal8 = Terminal::fromStreams($pipes8[0]); +try { + $terminal8->readSecret(); + echo "FAIL: expected abort on SS3+Ctrl+C\n"; +} catch (TerminalException $e) { + echo "SS3+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} +fwrite($pipes8[0], "done\n"); +unset($terminal8); +foreach ($pipes8 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc8); + +// 9. Valid CSI sequence (e.g. Up arrow \x1b[A) ignored and secret input continues +$code9 = 'fwrite(STDOUT, "\x1b[Asecret\n"); fflush(STDOUT); fgets(STDIN);'; +$proc9 = proc_open( + [PHP_BINARY, '-r', $code9], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes9, +); +$terminal9 = Terminal::fromStreams($pipes9[0]); +$secret9 = $terminal9->readSecret(); +echo "Valid CSI Up ignored: ", bin2hex($secret9), "\n"; +fwrite($pipes9[0], "done\n"); +unset($terminal9); +foreach ($pipes9 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc9); + +// 10. Valid SS3 sequence (e.g. F1 \x1bOP) ignored and secret input continues +$code10 = 'fwrite(STDOUT, "\x1bOPpassword\n"); fflush(STDOUT); fgets(STDIN);'; +$proc10 = proc_open( + [PHP_BINARY, '-r', $code10], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes10, +); +$terminal10 = Terminal::fromStreams($pipes10[0]); +$secret10 = $terminal10->readSecret(); +echo "Valid SS3 F1 ignored: ", bin2hex($secret10), "\n"; +fwrite($pipes10[0], "done\n"); +unset($terminal10); +foreach ($pipes10 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc10); + +// 11. Internal 25ms timeout inside incomplete CSI ends sequence skipping and continues secret +$code11 = 'fwrite(STDOUT, "\x1b["); fflush(STDOUT); usleep(35000); fwrite(STDOUT, "mysecret\n"); fflush(STDOUT); fgets(STDIN);'; +$proc11 = proc_open( + [PHP_BINARY, '-r', $code11], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes11, +); +$terminal11 = Terminal::fromStreams($pipes11[0]); +$secret11 = $terminal11->readSecret(); +echo "Internal timeout continue: ", bin2hex($secret11), "\n"; +fwrite($pipes11[0], "done\n"); +unset($terminal11); +foreach ($pipes11 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc11); + ?> --EXPECT-- SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal @@ -173,3 +311,9 @@ Fragmented UTF-8: c3a9 Disconnect: caught Io\Terminal\TerminalException: Unable to read secret from terminal Escape+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal Escape+Enter: caught Io\Terminal\TerminalException: Unable to read secret from terminal +CSI+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal +CSI+Ctrl+D: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SS3+Ctrl+C: caught Io\Terminal\TerminalException: Unable to read secret from terminal +Valid CSI Up ignored: 736563726574 +Valid SS3 F1 ignored: 70617373776f7264 +Internal timeout continue: 6d79736563726574 diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt new file mode 100644 index 000000000000..726205ca5b1a --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt @@ -0,0 +1,165 @@ +--TEST-- +Io\Terminal\Terminal: readSecret timeout, empty submission, and cancellation contracts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); +$terminal1 = Terminal::fromStreams($pipes1[0]); +$start = hrtime(true); +$secret1 = $terminal1->readSecret(Duration::fromMilliseconds(25)); +$elapsed_ms = (hrtime(true) - $start) / 1e6; + +var_dump($secret1 === null); +var_dump($elapsed_ms >= 20.0); + +fwrite($pipes1[0], "done\n"); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// 2. Empty submission returning empty string "" +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fwrite(STDOUT, "\n"); fflush(STDOUT); fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); +$terminal2 = Terminal::fromStreams($pipes2[0]); +$secret2 = $terminal2->readSecret(Duration::fromMilliseconds(100)); + +var_dump($secret2 === ""); + +fwrite($pipes2[0], "done\n"); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// 3. Normal submission returning text +$proc3 = proc_open( + [PHP_BINARY, '-r', 'fwrite(STDOUT, "secret123\n"); fflush(STDOUT); fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); +$terminal3 = Terminal::fromStreams($pipes3[0]); +$secret3 = $terminal3->readSecret(Duration::fromMilliseconds(100)); + +var_dump($secret3 === "secret123"); + +fwrite($pipes3[0], "done\n"); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// 4. Cancellation (Ctrl+C) throws TerminalException +$proc4 = proc_open( + [PHP_BINARY, '-r', 'fwrite(STDOUT, "\x03"); fflush(STDOUT); fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); +$terminal4 = Terminal::fromStreams($pipes4[0]); +try { + $terminal4->readSecret(Duration::fromMilliseconds(100)); + echo "FAIL: expected TerminalException on cancellation\n"; +} catch (TerminalException $e) { + echo "Cancellation caught: ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes4[0], "done\n"); +unset($terminal4); +foreach ($pipes4 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc4); + +// 5. Negative duration throws ValueError +$proc5 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes5, +); +$terminal5 = Terminal::fromStreams($pipes5[0]); +try { + $terminal5->readSecret(Duration::fromSeconds(1)->negate()); + echo "FAIL: expected ValueError on negative duration\n"; +} catch (ValueError $e) { + echo "Negative timeout caught: ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes5[0], "done\n"); +unset($terminal5); +foreach ($pipes5 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc5); + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +Cancellation caught: Unable to read secret from terminal +Negative timeout caught: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must not be negative diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt index 1682e1c5cc9f..76fb73d038b4 100644 --- a/ext/standard/tests/terminal/terminal_size.phpt +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: getSize returns false for non-terminal streams without environment fallback +Io\Terminal\Terminal: getSize returns null for non-terminal streams without environment fallback --ENV-- COLUMNS=100 LINES=30 @@ -15,4 +15,4 @@ var_dump($terminal->getSize()); ?> --EXPECT-- -bool(false) +NULL From 18b695f7a8daac14567eb97b28854508e8a6fe21 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 07:17:16 +0545 Subject: [PATCH 27/38] Fix terminal timeout edge cases --- ext/standard/io_terminal.c | 171 ++++++++++++------ .../terminal_read_key_partial_escape.phpt | 56 +++++- .../terminal_read_key_utf8_retain.phpt | 115 +++++++++++- .../terminal_read_secret_timeout.phpt | 82 +++++++++ 4 files changed, 359 insertions(+), 65 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 2cd74f1111a0..9ffe0917746e 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -54,9 +54,18 @@ static inline time_t php_io_terminal_clamp_duration_seconds(uint64_t seconds) return seconds > (uint64_t) INT64_MAX ? (time_t) INT64_MAX : (time_t) seconds; } +static inline zend_hrtime_t php_io_terminal_saturating_add_deadline(zend_hrtime_t base_ns, zend_hrtime_t duration_ns) +{ + if (UINT64_MAX - base_ns <= duration_ns) { + return UINT64_MAX; + } + return base_ns + duration_ns; +} + #ifdef PHP_WIN32 typedef HANDLE php_io_terminal_native_stream; # define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM INVALID_HANDLE_VALUE +# define PHP_IO_TERMINAL_MAX_WAIT_MS (INFINITE - 1) #else typedef int php_io_terminal_native_stream; # define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM (-1) @@ -66,7 +75,17 @@ static inline zend_hrtime_t php_io_terminal_timespec_to_ns(const struct timespec if (ts == NULL) { return 0; } - return ((zend_hrtime_t) ts->tv_sec * 1000000000ULL) + (zend_hrtime_t) ts->tv_nsec; + if (ts->tv_sec <= 0 && ts->tv_nsec <= 0) { + return 0; + } + if ((uint64_t) ts->tv_sec >= (UINT64_MAX / 1000000000ULL)) { + return UINT64_MAX; + } + zend_hrtime_t sec_ns = (zend_hrtime_t) ts->tv_sec * 1000000000ULL; + if (UINT64_MAX - sec_ns < (zend_hrtime_t) ts->tv_nsec) { + return UINT64_MAX; + } + return sec_ns + (zend_hrtime_t) ts->tv_nsec; } static inline void php_io_terminal_ns_to_timespec(zend_hrtime_t ns, struct timespec *ts) @@ -1074,7 +1093,10 @@ static zend_string *php_io_terminal_read_stream_secret( zend_hrtime_t overall_deadline_ns = 0; if (has_timeout) { - overall_deadline_ns = zend_hrtime() + ((zend_hrtime_t) wait_ms * 1000000ULL); + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + ((zend_hrtime_t) wait_ms * 1000000ULL) + ); } for (;;) { @@ -1086,13 +1108,12 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_timeout) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { - *timed_out = true; - failed = true; - break; + current_wait = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + uint64_t ms = (remaining_ns + 999999ULL) / 1000000ULL; + current_wait = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; } - zend_hrtime_t remaining_ns = overall_deadline_ns - now; - uint64_t ms = (remaining_ns + 999999ULL) / 1000000ULL; - current_wait = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; } if (!php_io_terminal_read_console_record( @@ -1102,7 +1123,7 @@ static zend_string *php_io_terminal_read_stream_secret( &high_surrogate, pending_key, pending_key_high_surrogate)) { - if (has_timeout && zend_hrtime() >= overall_deadline_ns) { + if (has_timeout && (current_wait == 0 || zend_hrtime() >= overall_deadline_ns)) { *timed_out = true; } failed = true; @@ -1159,6 +1180,7 @@ static zend_string *php_io_terminal_read_stream_secret( if (mode_changed && !SetConsoleMode(handle, mode)) { failed = true; + *timed_out = false; } if (success && !failed) { @@ -1423,47 +1445,56 @@ static zend_string *php_io_terminal_finish_utf8_sequence( const struct timespec *first_timeout, const struct timespec *sequence_timeout, bool has_overall_deadline, - zend_hrtime_t overall_deadline_ns + zend_hrtime_t overall_deadline_ns, + bool is_fresh ) { - size_t initial_length = pending->length; zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); - zend_hrtime_t deadline_ns = 0; - bool deadline_set = false; + bool is_first_resumed_read = !is_fresh; while (pending->length < pending->expected) { unsigned char key; struct timespec remaining_ts; const struct timespec *timeout; + zend_hrtime_t now = zend_hrtime(); - if (pending->length == initial_length) { + if (has_overall_deadline && now >= overall_deadline_ns) { + return NULL; + } + + if (is_first_resumed_read) { + /* When resuming a previously pending sequence on a new readKey() call, + * the first continuation read of that invocation may wait up to the + * new call's overall timeout (capped by overall deadline). */ if (has_overall_deadline) { - zend_hrtime_t now = zend_hrtime(); - if (now >= overall_deadline_ns) { - return NULL; + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + if (first_timeout != NULL) { + zend_hrtime_t first_ns = php_io_terminal_timespec_to_ns(first_timeout); + if (first_ns < remaining_ns) { + remaining_ns = first_ns; + } } - php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &remaining_ts); + php_io_terminal_ns_to_timespec(remaining_ns, &remaining_ts); timeout = &remaining_ts; } else { timeout = first_timeout; } } else { - if (!deadline_set) { - deadline_ns = zend_hrtime() + seq_timeout_ns; - if (has_overall_deadline && deadline_ns > overall_deadline_ns) { - deadline_ns = overall_deadline_ns; + /* Fresh continuation byte or subsequent resumed byte in this invocation: + * bounded by sequence_timeout, capped by overall deadline. */ + zend_hrtime_t step_ns = seq_timeout_ns; + if (has_overall_deadline) { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + if (step_ns > remaining_ns) { + step_ns = remaining_ns; } - deadline_set = true; - } - zend_hrtime_t now = zend_hrtime(); - if (now >= deadline_ns) { - return NULL; } - php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); + php_io_terminal_ns_to_timespec(step_ns, &remaining_ts); timeout = &remaining_ts; } int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); + is_first_resumed_read = false; if (result == PHP_IO_TERMINAL_READ_TIMEOUT) { return NULL; @@ -1505,7 +1536,7 @@ static zend_string *php_io_terminal_key_from_escape_sequence( zend_hrtime_t now = zend_hrtime(); zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); - zend_hrtime_t deadline_ns = now + seq_timeout_ns; + zend_hrtime_t deadline_ns = php_io_terminal_saturating_add_deadline(now, seq_timeout_ns); if (has_overall_deadline && deadline_ns > overall_deadline_ns) { deadline_ns = overall_deadline_ns; } @@ -1637,7 +1668,7 @@ static zend_string *php_io_terminal_key_from_utf8_sequence( return php_io_terminal_finish_utf8_sequence( fd, stream, poll_ctx, pending, sequence_timeout, sequence_timeout, - has_overall_deadline, overall_deadline_ns + has_overall_deadline, overall_deadline_ns, true ); } @@ -1723,7 +1754,10 @@ static zend_string *php_io_terminal_read_stream_key( bool has_overall_deadline = (timeout != NULL); zend_hrtime_t overall_deadline_ns = 0; if (has_overall_deadline) { - overall_deadline_ns = zend_hrtime() + php_io_terminal_timespec_to_ns(timeout); + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(timeout) + ); } zend_string *result = NULL; @@ -1731,7 +1765,7 @@ static zend_string *php_io_terminal_read_stream_key( if (pending->length > 0) { result = php_io_terminal_finish_utf8_sequence( fd, stream, poll_ctx, pending, timeout, sequence_timeout, - has_overall_deadline, overall_deadline_ns + has_overall_deadline, overall_deadline_ns, false ); } else { unsigned char key; @@ -1812,7 +1846,10 @@ static zend_string *php_io_terminal_read_stream_secret( bool has_overall_deadline = (timeout != NULL); zend_hrtime_t overall_deadline_ns = 0; if (has_overall_deadline) { - overall_deadline_ns = zend_hrtime() + php_io_terminal_timespec_to_ns(timeout); + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(timeout) + ); } smart_str secret = {0}; @@ -1826,10 +1863,11 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { - *timed_out = true; - goto restore; + current_timeout.tv_sec = 0; + current_timeout.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout); } - php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout); current_timeout_ptr = ¤t_timeout; } @@ -1864,12 +1902,13 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { - *timed_out = true; - goto restore; + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); } - zend_hrtime_t remaining_ns = overall_deadline_ns - now; - zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; - php_io_terminal_ns_to_timespec(step_ns, &step_timeout); step_timeout_ptr = &step_timeout; } else { step_timeout.tv_sec = 0; @@ -1880,10 +1919,6 @@ static zend_string *php_io_terminal_read_stream_secret( unsigned char next; int read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, step_timeout_ptr, false, NULL); if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { - if (has_overall_deadline && zend_hrtime() >= overall_deadline_ns) { - *timed_out = true; - goto restore; - } /* Standalone Escape cancels */ *timed_out = false; goto restore; @@ -1905,12 +1940,13 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { - *timed_out = true; - goto restore; + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); } - zend_hrtime_t remaining_ns = overall_deadline_ns - now; - zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; - php_io_terminal_ns_to_timespec(step_ns, &step_timeout); step_timeout_ptr = &step_timeout; } else { step_timeout.tv_sec = 0; @@ -1949,12 +1985,13 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { - *timed_out = true; - goto restore; + step_timeout.tv_sec = 0; + step_timeout.tv_nsec = 0; + } else { + zend_hrtime_t remaining_ns = overall_deadline_ns - now; + zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &step_timeout); } - zend_hrtime_t remaining_ns = overall_deadline_ns - now; - zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL; - php_io_terminal_ns_to_timespec(step_ns, &step_timeout); step_timeout_ptr = &step_timeout; } else { step_timeout.tv_sec = 0; @@ -2007,10 +2044,11 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { - *timed_out = true; - goto restore; + cont_timeout.tv_sec = 0; + cont_timeout.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cont_timeout); } - php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cont_timeout); cont_timeout_ptr = &cont_timeout; } @@ -2046,6 +2084,7 @@ static zend_string *php_io_terminal_read_stream_secret( restore: if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { success = false; + *timed_out = false; } php_poll_destroy(poll_ctx); @@ -2311,6 +2350,21 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) GC_ADDREF(intern->active_mode_token); } +/* + * Invariant: A native Terminal implementation may only restore a native + * ModeToken associated with the same underlying terminal/shared mode record. + * + * The native implementation intentionally allows a token to be restored by + * another Terminal object that refers to the same underlying terminal identity + * or shared mode record. + * + * TerminalInterface::restoreMode(?ModeTokenInterface $mode = null) accepts the + * interface to allow polymorphic userland terminal fakes (e.g. for unit testing). + * However, the native Terminal implementation requires native C state attached to + * an internal ModeToken instance associated with the same underlying terminal or + * shared mode record; foreign userland token implementations must still be rejected + * with ValueError. + */ PHP_METHOD(Io_Terminal_Terminal, restoreMode) { zval *mode_token = NULL; @@ -2437,7 +2491,6 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) } #ifdef PHP_WIN32 -# define PHP_IO_TERMINAL_MAX_WAIT_MS (INFINITE - 1) DWORD wait_ms = INFINITE; if (timeout_duration != NULL) { uint64_t sec = timeout_duration->duration.seconds; diff --git a/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt index 11965e262e56..64a4b04f9762 100644 --- a/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt @@ -36,10 +36,26 @@ use Io\Terminal\Key; use Io\Terminal\Terminal; use Time\Duration; -// Case 1: Overall deadline 30ms, ESC and [ sent before 30ms, A sent after 50ms. +// Case 1: Overall deadline 30ms, ESC and [ sent before 30ms, A sent after release. // The first call returns incomplete escape sequence "\x1b[" (hex 1b5b). // The subsequent call returns "A". -$code1 = 'fwrite(STDOUT, "\x1b["); fflush(STDOUT); usleep(60000); fwrite(STDOUT, "A"); fflush(STDOUT); fgets(STDIN);'; +$code1 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x1b["); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "A"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc1 = proc_open( [PHP_BINARY, '-r', $code1], [ @@ -50,10 +66,18 @@ $proc1 = proc_open( $pipes1, ); +$r0 = fgets($pipes1[2]); $terminal1 = Terminal::fromStreams($pipes1[0]); $terminal1->enableRawMode(); +fwrite($pipes1[0], "START\n"); +$r1 = fgets($pipes1[2]); + $k1 = $terminal1->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(100)); + +fwrite($pipes1[0], "GO\n"); +$r2 = fgets($pipes1[2]); + $k2 = $terminal1->readKey(Duration::fromMilliseconds(100)); echo "Case 1 k1 is string: ", var_export(is_string($k1), true), PHP_EOL; @@ -67,10 +91,26 @@ foreach ($pipes1 as $pipe) { } proc_close($proc1); -// Case 2: Overall deadline 30ms, ESC sent at 0ms, [ arrives at 80ms. +// Case 2: Overall deadline 30ms, ESC sent at 0ms, [ arrives after release. // Because [ does not arrive before the 30ms deadline, the first call returns Key::Escape. // The subsequent call reads [ independently. -$code2 = 'fwrite(STDOUT, "\x1b"); fflush(STDOUT); usleep(80000); fwrite(STDOUT, "["); fflush(STDOUT); fgets(STDIN);'; +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x1b"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "["); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc2 = proc_open( [PHP_BINARY, '-r', $code2], [ @@ -81,10 +121,18 @@ $proc2 = proc_open( $pipes2, ); +$r0 = fgets($pipes2[2]); $terminal2 = Terminal::fromStreams($pipes2[0]); $terminal2->enableRawMode(); +fwrite($pipes2[0], "START\n"); +$r1 = fgets($pipes2[2]); + $k3 = $terminal2->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(100)); + +fwrite($pipes2[0], "GO\n"); +$r2 = fgets($pipes2[2]); + $k4 = $terminal2->readKey(Duration::fromMilliseconds(100)); echo "Case 2 k3 is Escape: ", var_export($k3 === Key::Escape, true), PHP_EOL; diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt index 31d3483a2659..7941e35c233f 100644 --- a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt @@ -36,7 +36,23 @@ use Io\Terminal\Terminal; use Time\Duration; // 1. 3-byte UTF-8 (€: \xe2\x82\xac) split across timeout -$code1 = 'fwrite(STDOUT, "\xe2"); fflush(STDOUT); usleep(60000); fwrite(STDOUT, "\x82\xac"); fflush(STDOUT); fgets(STDIN);'; +$code1 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc1 = proc_open( [PHP_BINARY, '-r', $code1], [ @@ -47,10 +63,18 @@ $proc1 = proc_open( $pipes1, ); +$r0 = fgets($pipes1[2]); $terminal1 = Terminal::fromStreams($pipes1[0]); $terminal1->enableRawMode(); +fwrite($pipes1[0], "START\n"); +$r1 = fgets($pipes1[2]); + $k1 = $terminal1->readKey(Duration::fromMilliseconds(25)); + +fwrite($pipes1[0], "GO\n"); +$r2 = fgets($pipes1[2]); + $k2 = $terminal1->readKey(Duration::fromMilliseconds(100)); echo "k1 is null: ", var_export($k1 === null, true), PHP_EOL; @@ -65,7 +89,23 @@ foreach ($pipes1 as $pipe) { proc_close($proc1); // 2. 4-byte UTF-8 emoji (😀: \xf0\x9f\x98\x80) split across timeout -$code2 = 'fwrite(STDOUT, "\xf0\x9f"); fflush(STDOUT); usleep(60000); fwrite(STDOUT, "\x98\x80"); fflush(STDOUT); fgets(STDIN);'; +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xf0\x9f"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x98\x80"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc2 = proc_open( [PHP_BINARY, '-r', $code2], [ @@ -76,10 +116,18 @@ $proc2 = proc_open( $pipes2, ); +$r0 = fgets($pipes2[2]); $terminal2 = Terminal::fromStreams($pipes2[0]); $terminal2->enableRawMode(); +fwrite($pipes2[0], "START\n"); +$r1 = fgets($pipes2[2]); + $k3 = $terminal2->readKey(Duration::fromMilliseconds(25)); + +fwrite($pipes2[0], "GO\n"); +$r2 = fgets($pipes2[2]); + $k4 = $terminal2->readKey(Duration::fromMilliseconds(100)); echo "k3 is null: ", var_export($k3 === null, true), PHP_EOL; @@ -93,6 +141,66 @@ foreach ($pipes2 as $pipe) { } proc_close($proc2); +// 3. 4-byte UTF-8 sequence where more than one byte (2 bytes: \xf0\x9f) is already pending, +// and the remaining bytes arrive later than sequenceTimeout (20ms) but within the next call's +// overall timeout (150ms). +$code3 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xf0\x9f"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +usleep(40000); +fwrite(STDOUT, "\x98\x80"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc3 = proc_open( + [PHP_BINARY, '-r', $code3], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +$r0 = fgets($pipes3[2]); +$terminal3 = Terminal::fromStreams($pipes3[0]); +$terminal3->enableRawMode(); + +fwrite($pipes3[0], "START\n"); +$r1 = fgets($pipes3[2]); + +// Call 1 reads \xf0\x9f, waits for 3rd byte with sequenceTimeout 20ms, times out, retains 2 bytes. +$k5 = $terminal3->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(20)); + +// Release child: child sleeps 40ms (> 20ms sequenceTimeout) before writing remaining bytes. +fwrite($pipes3[0], "GO\n"); + +// Call 2 resumes sequence with 2 bytes already pending. The first continuation read of this invocation +// uses overall timeout (150ms) rather than sequenceTimeout (20ms), successfully waiting for the 40ms delay. +$k6 = $terminal3->readKey(Duration::fromMilliseconds(150), Duration::fromMilliseconds(20)); + +echo "k5 is null: ", var_export($k5 === null, true), PHP_EOL; +echo "k6 is emoji: ", var_export($k6 === "😀", true), PHP_EOL; +echo "k6 hex: ", bin2hex($k6), PHP_EOL; + +$r2 = fgets($pipes3[2]); +fwrite($pipes3[0], "done\n"); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + ?> --EXPECT-- k1 is null: true @@ -101,3 +209,6 @@ k2 hex: e282ac k3 is null: true k4 is emoji: true k4 hex: f09f9880 +k5 is null: true +k6 is emoji: true +k6 hex: f09f9880 diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt index 726205ca5b1a..8e87eb6c28f8 100644 --- a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt @@ -155,6 +155,86 @@ foreach ($pipes5 as $pipe) { } proc_close($proc5); +// 6. Complete secret plus newline already buffered: zero timeout consumes immediately available bytes without blocking +$code6 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "zerosecret\n"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc6 = proc_open( + [PHP_BINARY, '-r', $code6], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes6, +); +$r0 = fgets($pipes6[2]); +$terminal6 = Terminal::fromStreams($pipes6[0]); +$terminal6->enableRawMode(); + +fwrite($pipes6[0], "START\n"); +$r1 = fgets($pipes6[2]); + +$secret6 = $terminal6->readSecret(Duration::fromSeconds(0)); +var_dump($secret6 === "zerosecret"); + +fwrite($pipes6[0], "done\n"); +unset($terminal6); +foreach ($pipes6 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc6); + +// 7. Already-buffered standalone Escape consumed with zero timeout: follows cancellation contract and throws TerminalException +$code7 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x1b"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +$proc7 = proc_open( + [PHP_BINARY, '-r', $code7], + [ + 0 => ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes7, +); +$r0 = fgets($pipes7[2]); +$terminal7 = Terminal::fromStreams($pipes7[0]); +$terminal7->enableRawMode(); + +fwrite($pipes7[0], "START\n"); +$r1 = fgets($pipes7[2]); + +try { + $terminal7->readSecret(Duration::fromSeconds(0)); + echo "FAIL: expected TerminalException on zero-timeout buffered escape\n"; +} catch (TerminalException $e) { + echo "Zero timeout escape caught: ", $e->getMessage(), PHP_EOL; +} + +fwrite($pipes7[0], "done\n"); +unset($terminal7); +foreach ($pipes7 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc7); + ?> --EXPECTF-- bool(true) @@ -163,3 +243,5 @@ bool(true) bool(true) Cancellation caught: Unable to read secret from terminal Negative timeout caught: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must not be negative +bool(true) +Zero timeout escape caught: Unable to read secret from terminal From baeda5b0a2db33d1e8946c2b57b058368bdea20f Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 08:07:54 +0545 Subject: [PATCH 28/38] Stabilize readSecret PTY tests --- .../terminal_read_secret_timeout.phpt | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt index 8e87eb6c28f8..f3ea09a44bbe 100644 --- a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt @@ -62,8 +62,19 @@ foreach ($pipes1 as $pipe) { proc_close($proc1); // 2. Empty submission returning empty string "" +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\n"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc2 = proc_open( - [PHP_BINARY, '-r', 'fwrite(STDOUT, "\n"); fflush(STDOUT); fgets(STDIN);'], + [PHP_BINARY, '-r', $code2], [ 0 => ['pty'], 1 => ['pty'], @@ -71,7 +82,13 @@ $proc2 = proc_open( ], $pipes2, ); +$r0 = fgets($pipes2[2]); $terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2->enableRawMode(); + +fwrite($pipes2[0], "START\n"); +$r1 = fgets($pipes2[2]); + $secret2 = $terminal2->readSecret(Duration::fromMilliseconds(100)); var_dump($secret2 === ""); @@ -84,8 +101,19 @@ foreach ($pipes2 as $pipe) { proc_close($proc2); // 3. Normal submission returning text +$code3 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "secret123\n"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc3 = proc_open( - [PHP_BINARY, '-r', 'fwrite(STDOUT, "secret123\n"); fflush(STDOUT); fgets(STDIN);'], + [PHP_BINARY, '-r', $code3], [ 0 => ['pty'], 1 => ['pty'], @@ -93,7 +121,13 @@ $proc3 = proc_open( ], $pipes3, ); +$r0 = fgets($pipes3[2]); $terminal3 = Terminal::fromStreams($pipes3[0]); +$terminal3->enableRawMode(); + +fwrite($pipes3[0], "START\n"); +$r1 = fgets($pipes3[2]); + $secret3 = $terminal3->readSecret(Duration::fromMilliseconds(100)); var_dump($secret3 === "secret123"); @@ -106,8 +140,19 @@ foreach ($pipes3 as $pipe) { proc_close($proc3); // 4. Cancellation (Ctrl+C) throws TerminalException +$code4 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x03"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; $proc4 = proc_open( - [PHP_BINARY, '-r', 'fwrite(STDOUT, "\x03"); fflush(STDOUT); fgets(STDIN);'], + [PHP_BINARY, '-r', $code4], [ 0 => ['pty'], 1 => ['pty'], @@ -115,7 +160,13 @@ $proc4 = proc_open( ], $pipes4, ); +$r0 = fgets($pipes4[2]); $terminal4 = Terminal::fromStreams($pipes4[0]); +$terminal4->enableRawMode(); + +fwrite($pipes4[0], "START\n"); +$r1 = fgets($pipes4[2]); + try { $terminal4->readSecret(Duration::fromMilliseconds(100)); echo "FAIL: expected TerminalException on cancellation\n"; From 161defed0898db47d0afad2992e293a45b2f7a21 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 11:35:11 +0545 Subject: [PATCH 29/38] Address terminal review feedback --- ext/standard/io_terminal.c | 108 +++++++++-------- ext/standard/io_terminal.stub.php | 181 ++++++++++++++--------------- ext/standard/io_terminal_arginfo.h | 2 +- ext/standard/io_terminal_decl.h | 8 +- 4 files changed, 148 insertions(+), 151 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 9ffe0917746e..cd012fa76750 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -336,32 +336,32 @@ static bool php_io_terminal_identities_match(const php_io_terminal_identity *fir # define PATH_MAX 1024 #endif -static bool php_io_terminal_get_slave_dev_from_pty_master(int fd, dev_t *slave_dev) +static bool php_io_terminal_get_pty_peer_dev(int fd, dev_t *peer_dev) { - char slave_path[PATH_MAX]; - bool got_slave_name = false; + char peer_path[PATH_MAX]; + bool got_peer_name = false; #if defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) - if (ptsname_r(fd, slave_path, sizeof(slave_path)) == 0) { - got_slave_name = true; + if (ptsname_r(fd, peer_path, sizeof(peer_path)) == 0) { + got_peer_name = true; } #else PHP_IO_TERMINAL_PTSNAME_LOCK(); const char *pts = ptsname(fd); if (pts != NULL) { size_t len = strlen(pts); - if (len < sizeof(slave_path)) { - memcpy(slave_path, pts, len + 1); - got_slave_name = true; + if (len < sizeof(peer_path)) { + memcpy(peer_path, pts, len + 1); + got_peer_name = true; } } PHP_IO_TERMINAL_PTSNAME_UNLOCK(); #endif - if (got_slave_name && slave_path[0] != '\0') { + if (got_peer_name && peer_path[0] != '\0') { struct stat st; - if (stat(slave_path, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) { - *slave_dev = st.st_rdev; + if (stat(peer_path, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) { + *peer_dev = st.st_rdev; return true; } } @@ -385,9 +385,9 @@ static bool php_io_terminal_get_identity(php_io_terminal_native_stream fd, php_i identity->has_sid = true; } - dev_t slave_dev = 0; - if (php_io_terminal_get_slave_dev_from_pty_master(fd, &slave_dev)) { - identity->dev = slave_dev; + dev_t peer_dev = 0; + if (php_io_terminal_get_pty_peer_dev(fd, &peer_dev)) { + identity->dev = peer_dev; identity->has_dev = true; return true; } @@ -523,7 +523,6 @@ static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mod if (GetConsoleMode(shared->restore_stream, &actual_mode) && actual_mode != shared->saved_mode) { return false; } - return true; #else if (tcsetattr(shared->restore_stream, TCSANOW, &shared->saved_mode) != 0) { return false; @@ -534,8 +533,8 @@ static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mod return false; } } - return true; #endif + return true; } static void php_io_terminal_destroy_shared_mode(php_io_terminal_shared_mode *shared) @@ -598,8 +597,7 @@ static void php_io_terminal_mode_token_free_obj(zend_object *object) php_io_terminal_mode_token_object *intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(object); if (intern->valid && intern->shared != NULL) { - const char *err = NULL; - if (!php_io_terminal_release_token_lease(intern, &err)) { + if (!php_io_terminal_release_token_lease(intern, NULL)) { intern->valid = false; intern->shared = NULL; } @@ -941,8 +939,6 @@ static bool php_io_terminal_read_console_record( WCHAR *pending_key_high_surrogate ) { - DWORD records_read; - if (pending_key->Event.KeyEvent.wRepeatCount > 0) { *record = *pending_key; *high_surrogate = *pending_key_high_surrogate; @@ -959,6 +955,7 @@ static bool php_io_terminal_read_console_record( return false; } + DWORD records_read; if (!ReadConsoleInputW(handle, record, 1, &records_read) || records_read != 1) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to read console input", 0); return false; @@ -979,7 +976,6 @@ static zend_string *php_io_terminal_read_stream_key( HANDLE handle = input; DWORD mode = 0; WCHAR high_surrogate = *pending_high_surrogate; - DWORD raw_mode; ULONGLONG deadline_ms = wait_ms == INFINITE ? 0 : GetTickCount64() + wait_ms; zend_string *result = NULL; bool mode_changed = false; @@ -995,14 +991,14 @@ static zend_string *php_io_terminal_read_stream_key( return NULL; } - raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; + DWORD raw_mode = php_io_terminal_make_raw_mode(mode) | ENABLE_WINDOW_INPUT; if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to set console mode", 0); return NULL; } mode_changed = raw_mode != mode; - for (;;) { + while (true) { INPUT_RECORD record; DWORD remaining_ms = INFINITE; @@ -1099,10 +1095,8 @@ static zend_string *php_io_terminal_read_stream_secret( ); } - for (;;) { + while (true) { INPUT_RECORD record; - KEY_EVENT_RECORD *key; - WORD repeats; DWORD current_wait = INFINITE; if (has_timeout) { @@ -1134,7 +1128,7 @@ static zend_string *php_io_terminal_read_stream_secret( continue; } - key = &record.Event.KeyEvent; + KEY_EVENT_RECORD *key = &record.Event.KeyEvent; if (!key->bKeyDown) { continue; } @@ -1151,7 +1145,7 @@ static zend_string *php_io_terminal_read_stream_secret( break; } - repeats = key->wRepeatCount > 0 ? key->wRepeatCount : 1; + WORD repeats = key->wRepeatCount > 0 ? key->wRepeatCount : 1; while (repeats-- > 0) { if (key->wVirtualKeyCode == VK_BACK) { if (secret.s != NULL && ZSTR_LEN(secret.s) > 0) { @@ -1383,7 +1377,7 @@ static int php_io_terminal_read_byte( remaining = *timeout; } - for (;;) { + while (true) { php_poll_event event; const struct timespec *wait_timeout = NULL; zend_hrtime_t start_ns = 0; @@ -1510,9 +1504,7 @@ static zend_string *php_io_terminal_finish_utf8_sequence( pending->bytes[pending->length++] = key; if ((key & 0xc0) != 0x80) { - zend_string *invalid = zend_string_init((const char *) pending->bytes, pending->length, false); - memset(pending, 0, sizeof(*pending)); - return invalid; + break; } } @@ -1591,29 +1583,35 @@ static zend_string *php_io_terminal_key_from_escape_sequence( } #define PHP_IO_TERMINAL_CSI_IS(literal) \ - (seq_len == sizeof(literal) - 1 && memcmp(seq, literal, sizeof(literal) - 1) == 0) - - if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return ZSTR_INIT_LITERAL("up", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return ZSTR_INIT_LITERAL("down", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return ZSTR_INIT_LITERAL("right", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return ZSTR_INIT_LITERAL("left", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[H") || PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return ZSTR_INIT_LITERAL("home", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[F") || PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return ZSTR_INIT_LITERAL("end", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return ZSTR_INIT_LITERAL("delete", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return ZSTR_INIT_LITERAL("pageup", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return ZSTR_INIT_LITERAL("pagedown", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return ZSTR_INIT_LITERAL("f1", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return ZSTR_INIT_LITERAL("f2", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return ZSTR_INIT_LITERAL("f3", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return ZSTR_INIT_LITERAL("f4", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return ZSTR_INIT_LITERAL("f5", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return ZSTR_INIT_LITERAL("f6", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return ZSTR_INIT_LITERAL("f7", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return ZSTR_INIT_LITERAL("f8", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return ZSTR_INIT_LITERAL("f9", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return ZSTR_INIT_LITERAL("f10", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return ZSTR_INIT_LITERAL("f11", false); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return ZSTR_INIT_LITERAL("f12", false); + (memcmp(seq, literal, sizeof(literal) - 1) == 0) + + if (seq_len == 3) { + if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return ZSTR_INIT_LITERAL("up", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return ZSTR_INIT_LITERAL("down", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return ZSTR_INIT_LITERAL("right", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return ZSTR_INIT_LITERAL("left", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[H")) return ZSTR_INIT_LITERAL("home", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[F")) return ZSTR_INIT_LITERAL("end", false); + } else if (seq_len == 4) { + if (PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return ZSTR_INIT_LITERAL("home", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return ZSTR_INIT_LITERAL("end", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return ZSTR_INIT_LITERAL("delete", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return ZSTR_INIT_LITERAL("pageup", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return ZSTR_INIT_LITERAL("pagedown", false); + } else if (seq_len == 5) { + if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return ZSTR_INIT_LITERAL("f1", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return ZSTR_INIT_LITERAL("f2", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return ZSTR_INIT_LITERAL("f3", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return ZSTR_INIT_LITERAL("f4", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return ZSTR_INIT_LITERAL("f5", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return ZSTR_INIT_LITERAL("f6", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return ZSTR_INIT_LITERAL("f7", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return ZSTR_INIT_LITERAL("f8", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return ZSTR_INIT_LITERAL("f9", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return ZSTR_INIT_LITERAL("f10", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return ZSTR_INIT_LITERAL("f11", false); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return ZSTR_INIT_LITERAL("f12", false); + } #undef PHP_IO_TERMINAL_CSI_IS } else if (seq[1] == 'O' && seq_len < sizeof(seq)) { diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index e85a24577563..40d89d711339 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -5,104 +5,103 @@ * @generate-c-enums */ -namespace Io\Terminal { - - /** @strict-properties */ - class TerminalException extends \Io\IoException {} - - enum Key - { - case Up; - case Down; - case Right; - case Left; - case Enter; - case Backspace; - case Escape; - case Tab; - case Home; - case End; - case Delete; - case PageUp; - case PageDown; - case Resize; - case F1; - case F2; - case F3; - case F4; - case F5; - case F6; - case F7; - case F8; - case F9; - case F10; - case F11; - case F12; - } - - /** @strict-properties */ - final readonly class TerminalSize - { - public readonly int $cols; - public readonly int $rows; - - public function __construct(int $cols, int $rows) {} - } - - interface ModeTokenInterface {} - - interface TerminalInterface - { - public function getSize(): ?TerminalSize; - - public function enableRawMode(): ModeTokenInterface; - - public function restoreMode(?ModeTokenInterface $mode = null): bool; - - public function readKey( - ?\Time\Duration $timeout = null, - ?\Time\Duration $sequenceTimeout = null, - ): Key|string|null; - - public function readSecret(?\Time\Duration $timeout = null): ?string; - } +namespace Io\Terminal; + +/** @strict-properties */ +class TerminalException extends \Io\IoException {} + +enum Key +{ + case Up; + case Down; + case Right; + case Left; + case Enter; + case Backspace; + case Escape; + case Tab; + case Home; + case End; + case Delete; + case PageUp; + case PageDown; + case Resize; + case F1; + case F2; + case F3; + case F4; + case F5; + case F6; + case F7; + case F8; + case F9; + case F10; + case F11; + case F12; +} - /** - * @strict-properties - * @not-serializable - */ - final class ModeToken implements ModeTokenInterface - { - private function __construct() {} - } +/** @strict-properties */ +final readonly class TerminalSize +{ + public readonly int $cols; + public readonly int $rows; - /** - * @strict-properties - * @not-serializable - */ - final class Terminal implements TerminalInterface - { - private function __construct() {} + public function __construct(int $cols, int $rows) {} +} + +interface ModeTokenInterface {} + +interface TerminalInterface +{ + public function getSize(): ?TerminalSize; + + public function enableRawMode(): ModeTokenInterface; + + public function restoreMode(?ModeTokenInterface $mode = null): bool; + + public function readKey( + ?\Time\Duration $timeout = null, + ?\Time\Duration $sequenceTimeout = null, + ): Key|string|null; - public static function fromStdio(): Terminal {} + public function readSecret(?\Time\Duration $timeout = null): ?string; +} + +/** + * @strict-properties + * @not-serializable + */ +final class ModeToken implements ModeTokenInterface +{ + private function __construct() {} +} - /** - * @param resource $input - * @param resource|null $output - */ - public static function fromStreams($input, $output = null): Terminal {} +/** + * @strict-properties + * @not-serializable + */ +final class Terminal implements TerminalInterface +{ + private function __construct() {} + + public static function fromStdio(): Terminal {} + + /** + * @param resource $input + * @param resource|null $output + */ + public static function fromStreams($input, $output = null): Terminal {} - public function getSize(): ?TerminalSize {} + public function getSize(): ?TerminalSize {} - public function enableRawMode(): ModeToken {} + public function enableRawMode(): ModeToken {} - public function restoreMode(?ModeTokenInterface $mode = null): bool {} + public function restoreMode(?ModeTokenInterface $mode = null): bool {} - public function readKey( - ?\Time\Duration $timeout = null, - ?\Time\Duration $sequenceTimeout = null, - ): Key|string|null {} + public function readKey( + ?\Time\Duration $timeout = null, + ?\Time\Duration $sequenceTimeout = null, + ): Key|string|null {} - public function readSecret(?\Time\Duration $timeout = null): ?string {} - } + public function readSecret(?\Time\Duration $timeout = null): ?string {} } diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 47801aa1f343..f10140901f71 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b + * Stub hash: 4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7 * Has decl header: yes */ #include "zend_enum.h" diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index c864f22b91b5..baf7ce65c2b9 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b */ + * Stub hash: 4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7 */ -#ifndef ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H -#define ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H +#ifndef ZEND_IO_TERMINAL_DECL_4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7_H +#define ZEND_IO_TERMINAL_DECL_4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H */ +#endif /* ZEND_IO_TERMINAL_DECL_4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7_H */ From 10d653ace0aebb01d19235741567ad85bedd55b8 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 14:03:58 +0545 Subject: [PATCH 30/38] Keep braced terminal namespace --- ext/standard/io_terminal.stub.php | 181 +++++++++++++++-------------- ext/standard/io_terminal_arginfo.h | 2 +- ext/standard/io_terminal_decl.h | 8 +- 3 files changed, 96 insertions(+), 95 deletions(-) diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index 40d89d711339..e85a24577563 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -5,103 +5,104 @@ * @generate-c-enums */ -namespace Io\Terminal; - -/** @strict-properties */ -class TerminalException extends \Io\IoException {} - -enum Key -{ - case Up; - case Down; - case Right; - case Left; - case Enter; - case Backspace; - case Escape; - case Tab; - case Home; - case End; - case Delete; - case PageUp; - case PageDown; - case Resize; - case F1; - case F2; - case F3; - case F4; - case F5; - case F6; - case F7; - case F8; - case F9; - case F10; - case F11; - case F12; -} - -/** @strict-properties */ -final readonly class TerminalSize -{ - public readonly int $cols; - public readonly int $rows; - - public function __construct(int $cols, int $rows) {} -} - -interface ModeTokenInterface {} - -interface TerminalInterface -{ - public function getSize(): ?TerminalSize; - - public function enableRawMode(): ModeTokenInterface; - - public function restoreMode(?ModeTokenInterface $mode = null): bool; - - public function readKey( - ?\Time\Duration $timeout = null, - ?\Time\Duration $sequenceTimeout = null, - ): Key|string|null; - - public function readSecret(?\Time\Duration $timeout = null): ?string; -} +namespace Io\Terminal { + + /** @strict-properties */ + class TerminalException extends \Io\IoException {} + + enum Key + { + case Up; + case Down; + case Right; + case Left; + case Enter; + case Backspace; + case Escape; + case Tab; + case Home; + case End; + case Delete; + case PageUp; + case PageDown; + case Resize; + case F1; + case F2; + case F3; + case F4; + case F5; + case F6; + case F7; + case F8; + case F9; + case F10; + case F11; + case F12; + } + + /** @strict-properties */ + final readonly class TerminalSize + { + public readonly int $cols; + public readonly int $rows; + + public function __construct(int $cols, int $rows) {} + } + + interface ModeTokenInterface {} + + interface TerminalInterface + { + public function getSize(): ?TerminalSize; + + public function enableRawMode(): ModeTokenInterface; + + public function restoreMode(?ModeTokenInterface $mode = null): bool; + + public function readKey( + ?\Time\Duration $timeout = null, + ?\Time\Duration $sequenceTimeout = null, + ): Key|string|null; + + public function readSecret(?\Time\Duration $timeout = null): ?string; + } -/** - * @strict-properties - * @not-serializable - */ -final class ModeToken implements ModeTokenInterface -{ - private function __construct() {} -} - -/** - * @strict-properties - * @not-serializable - */ -final class Terminal implements TerminalInterface -{ - private function __construct() {} - - public static function fromStdio(): Terminal {} + /** + * @strict-properties + * @not-serializable + */ + final class ModeToken implements ModeTokenInterface + { + private function __construct() {} + } /** - * @param resource $input - * @param resource|null $output + * @strict-properties + * @not-serializable */ - public static function fromStreams($input, $output = null): Terminal {} + final class Terminal implements TerminalInterface + { + private function __construct() {} + + public static function fromStdio(): Terminal {} + + /** + * @param resource $input + * @param resource|null $output + */ + public static function fromStreams($input, $output = null): Terminal {} - public function getSize(): ?TerminalSize {} + public function getSize(): ?TerminalSize {} - public function enableRawMode(): ModeToken {} + public function enableRawMode(): ModeToken {} - public function restoreMode(?ModeTokenInterface $mode = null): bool {} + public function restoreMode(?ModeTokenInterface $mode = null): bool {} - public function readKey( - ?\Time\Duration $timeout = null, - ?\Time\Duration $sequenceTimeout = null, - ): Key|string|null {} + public function readKey( + ?\Time\Duration $timeout = null, + ?\Time\Duration $sequenceTimeout = null, + ): Key|string|null {} - public function readSecret(?\Time\Duration $timeout = null): ?string {} + public function readSecret(?\Time\Duration $timeout = null): ?string {} + } } diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index f10140901f71..47801aa1f343 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7 + * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b * Has decl header: yes */ #include "zend_enum.h" diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index baf7ce65c2b9..c864f22b91b5 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7 */ + * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b */ -#ifndef ZEND_IO_TERMINAL_DECL_4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7_H -#define ZEND_IO_TERMINAL_DECL_4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7_H +#ifndef ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H +#define ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_4ee09e1e6eea30b57ca695b823fc64c8cfa62ee7_H */ +#endif /* ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H */ From e4792d8fdda5ad585b35517f9b10d83b3682d2ee Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 29 Sep 2026 21:32:21 +0545 Subject: [PATCH 31/38] Fix UTF-8 readKey timeout handling --- ext/standard/io_terminal.c | 64 ++--- .../terminal_read_key_utf8_timing.phpt | 226 ++++++++++++++++++ 2 files changed, 241 insertions(+), 49 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index cd012fa76750..e62386d8827d 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1436,60 +1436,27 @@ static zend_string *php_io_terminal_finish_utf8_sequence( php_stream *stream, php_poll_ctx *poll_ctx, php_io_terminal_utf8_pending *pending, - const struct timespec *first_timeout, - const struct timespec *sequence_timeout, bool has_overall_deadline, - zend_hrtime_t overall_deadline_ns, - bool is_fresh + zend_hrtime_t overall_deadline_ns ) { - zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); - bool is_first_resumed_read = !is_fresh; - while (pending->length < pending->expected) { unsigned char key; - struct timespec remaining_ts; - const struct timespec *timeout; - zend_hrtime_t now = zend_hrtime(); - - if (has_overall_deadline && now >= overall_deadline_ns) { - return NULL; - } + struct timespec current_timeout; + const struct timespec *timeout = NULL; - if (is_first_resumed_read) { - /* When resuming a previously pending sequence on a new readKey() call, - * the first continuation read of that invocation may wait up to the - * new call's overall timeout (capped by overall deadline). */ - if (has_overall_deadline) { - zend_hrtime_t remaining_ns = overall_deadline_ns - now; - if (first_timeout != NULL) { - zend_hrtime_t first_ns = php_io_terminal_timespec_to_ns(first_timeout); - if (first_ns < remaining_ns) { - remaining_ns = first_ns; - } - } - php_io_terminal_ns_to_timespec(remaining_ns, &remaining_ts); - timeout = &remaining_ts; + if (has_overall_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= overall_deadline_ns) { + current_timeout.tv_sec = 0; + current_timeout.tv_nsec = 0; } else { - timeout = first_timeout; - } - } else { - /* Fresh continuation byte or subsequent resumed byte in this invocation: - * bounded by sequence_timeout, capped by overall deadline. */ - zend_hrtime_t step_ns = seq_timeout_ns; - if (has_overall_deadline) { - zend_hrtime_t remaining_ns = overall_deadline_ns - now; - if (step_ns > remaining_ns) { - step_ns = remaining_ns; - } + php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout); } - php_io_terminal_ns_to_timespec(step_ns, &remaining_ts); - timeout = &remaining_ts; + timeout = ¤t_timeout; } int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); - is_first_resumed_read = false; - if (result == PHP_IO_TERMINAL_READ_TIMEOUT) { return NULL; } @@ -1647,7 +1614,6 @@ static zend_string *php_io_terminal_key_from_utf8_sequence( php_stream *stream, php_poll_ctx *poll_ctx, unsigned char key, - const struct timespec *sequence_timeout, php_io_terminal_utf8_pending *pending, bool has_overall_deadline, zend_hrtime_t overall_deadline_ns @@ -1665,8 +1631,8 @@ static zend_string *php_io_terminal_key_from_utf8_sequence( pending->expected = sequence_len; return php_io_terminal_finish_utf8_sequence( - fd, stream, poll_ctx, pending, sequence_timeout, sequence_timeout, - has_overall_deadline, overall_deadline_ns, true + fd, stream, poll_ctx, pending, + has_overall_deadline, overall_deadline_ns ); } @@ -1696,7 +1662,7 @@ static zend_string *php_io_terminal_key_from_byte( ); default: return php_io_terminal_key_from_utf8_sequence( - fd, stream, poll_ctx, key, sequence_timeout, pending, has_overall_deadline, overall_deadline_ns + fd, stream, poll_ctx, key, pending, has_overall_deadline, overall_deadline_ns ); } } @@ -1762,8 +1728,8 @@ static zend_string *php_io_terminal_read_stream_key( if (pending->length > 0) { result = php_io_terminal_finish_utf8_sequence( - fd, stream, poll_ctx, pending, timeout, sequence_timeout, - has_overall_deadline, overall_deadline_ns, false + fd, stream, poll_ctx, pending, + has_overall_deadline, overall_deadline_ns ); } else { unsigned char key; diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt new file mode 100644 index 000000000000..5e53328eee06 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt @@ -0,0 +1,226 @@ +--TEST-- +Io\Terminal\Terminal: readKey UTF-8 timing and overall timeout behavior +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + $started = fgets($pipes[2]); + $terminal = Terminal::fromStreams($pipes[0]); + $terminal->enableRawMode(); + return [$proc, $pipes, $terminal]; +} + +// Case 1: Complete queued 3-byte UTF-8 (€) with Duration::fromSeconds(0) +$code1 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc1, $pipes1, $terminal1] = spawn_pty_child($code1); +fwrite($pipes1[0], "START\n"); +$r1 = fgets($pipes1[2]); + +$k1 = $terminal1->readKey(Duration::fromSeconds(0)); +echo "Case 1: ", var_export($k1 === "€", true), " hex: ", bin2hex($k1), PHP_EOL; + +fwrite($pipes1[0], "done\n"); +$terminal1->restoreMode(); +unset($terminal1); +foreach ($pipes1 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc1); + +// Case 2: Complete queued 4-byte UTF-8 (😀) with Duration::fromSeconds(0) +$code2 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xf0\x9f\x98\x80"); +fflush(STDOUT); +fwrite(STDERR, "READY\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc2, $pipes2, $terminal2] = spawn_pty_child($code2); +fwrite($pipes2[0], "START\n"); +$r2 = fgets($pipes2[2]); + +$k2 = $terminal2->readKey(Duration::fromSeconds(0)); +echo "Case 2: ", var_export($k2 === "😀", true), " hex: ", bin2hex($k2), PHP_EOL; + +fwrite($pipes2[0], "done\n"); +$terminal2->restoreMode(); +unset($terminal2); +foreach ($pipes2 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc2); + +// Case 3: Retained UTF-8 lead byte followed by queued continuation bytes completed by subsequent Duration::fromSeconds(0) +$code3 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc3, $pipes3, $terminal3] = spawn_pty_child($code3); +fwrite($pipes3[0], "START\n"); +$r3_1 = fgets($pipes3[2]); + +$k3_1 = $terminal3->readKey(Duration::fromMilliseconds(25)); +echo "Case 3 k3_1 is null: ", var_export($k3_1 === null, true), PHP_EOL; + +fwrite($pipes3[0], "GO\n"); +$r3_2 = fgets($pipes3[2]); + +$k3_2 = $terminal3->readKey(Duration::fromSeconds(0)); +echo "Case 3 k3_2 is euro: ", var_export($k3_2 === "€", true), " hex: ", bin2hex($k3_2), PHP_EOL; + +fwrite($pipes3[0], "done\n"); +$terminal3->restoreMode(); +unset($terminal3); +foreach ($pipes3 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc3); + +// Case 4: Fresh fragmented UTF-8 where continuation arrives later than sequenceTimeout (50ms vs 20ms) but before finite overall timeout (500ms) +$code4 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +usleep(50000); +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc4, $pipes4, $terminal4] = spawn_pty_child($code4); +fwrite($pipes4[0], "START\n"); +$r4_1 = fgets($pipes4[2]); + +fwrite($pipes4[0], "GO\n"); +$k4 = $terminal4->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(20)); +echo "Case 4: ", var_export($k4 === "€", true), " hex: ", bin2hex($k4), PHP_EOL; + +$r4_2 = fgets($pipes4[2]); +fwrite($pipes4[0], "done\n"); +$terminal4->restoreMode(); +unset($terminal4); +foreach ($pipes4 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc4); + +// Case 5: readKey(timeout: null, sequenceTimeout: 25ms) where continuation arrives after >25ms (60ms); blocks and returns the complete character +$code5 = ' +fwrite(STDERR, "STARTED\n"); +fflush(STDERR); +fgets(STDIN); + +fwrite(STDOUT, "\xe2"); +fflush(STDOUT); +fwrite(STDERR, "READY1\n"); +fflush(STDERR); +fgets(STDIN); + +usleep(60000); +fwrite(STDOUT, "\x82\xac"); +fflush(STDOUT); +fwrite(STDERR, "READY2\n"); +fflush(STDERR); +fgets(STDIN); +'; +[$proc5, $pipes5, $terminal5] = spawn_pty_child($code5); +fwrite($pipes5[0], "START\n"); +$r5_1 = fgets($pipes5[2]); + +fwrite($pipes5[0], "GO\n"); +$k5 = $terminal5->readKey(timeout: null, sequenceTimeout: Duration::fromMilliseconds(25)); +echo "Case 5: ", var_export($k5 === "€", true), " hex: ", bin2hex($k5), PHP_EOL; + +$r5_2 = fgets($pipes5[2]); +fwrite($pipes5[0], "done\n"); +$terminal5->restoreMode(); +unset($terminal5); +foreach ($pipes5 as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc5); + +?> +--EXPECT-- +Case 1: true hex: e282ac +Case 2: true hex: f09f9880 +Case 3 k3_1 is null: true +Case 3 k3_2 is euro: true hex: e282ac +Case 4: true hex: e282ac +Case 5: true hex: e282ac From e760af08b310fa3d40bfe2f84a007ddb80a1535f Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 10:16:20 +0545 Subject: [PATCH 32/38] Refine terminal contracts and add line input --- ...flectionExtension_getClassNames_basic.phpt | 4 +- ext/standard/io_terminal.c | 415 ++++++++++++++++-- ext/standard/io_terminal.stub.php | 37 +- ext/standard/io_terminal_arginfo.h | 112 ++--- ext/standard/io_terminal_decl.h | 8 +- .../terminal/terminal_controlling_tty.phpt | 8 +- .../tests/terminal/terminal_create.phpt | 42 +- .../tests/terminal/terminal_identity.phpt | 16 +- .../terminal/terminal_interface_mock.phpt | 39 +- .../terminal_overlapping_raw_mode.phpt | 20 +- .../tests/terminal/terminal_raw_mode.phpt | 18 +- .../terminal/terminal_raw_mode_non_tty.phpt | 6 +- .../tests/terminal/terminal_read_key.phpt | 12 +- .../terminal/terminal_read_key_contracts.phpt | 12 +- .../terminal_read_key_partial_escape.phpt | 6 +- .../terminal/terminal_read_key_sequences.phpt | 6 +- .../terminal/terminal_read_key_timeout.phpt | 4 +- .../terminal_read_key_utf8_retain.phpt | 8 +- .../terminal_read_key_utf8_timing.phpt | 4 +- .../tests/terminal/terminal_read_line.phpt | 148 +++++++ .../terminal/terminal_read_line_lease.phpt | 151 +++++++ .../tests/terminal/terminal_read_secret.phpt | 12 +- .../terminal_read_secret_control.phpt | 24 +- .../terminal_read_secret_timeout.phpt | 20 +- .../tests/terminal/terminal_resize.phpt | 4 +- .../tests/terminal/terminal_size.phpt | 6 +- 26 files changed, 915 insertions(+), 227 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_read_line.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_line_lease.phpt diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index 9caa099dd137..0259162e7def 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -32,10 +32,10 @@ Io\Poll\PollException Io\Poll\Watcher Io\Terminal\Key Io\Terminal\ModeToken -Io\Terminal\ModeTokenInterface +Io\Terminal\SystemModeToken +Io\Terminal\SystemTerminal Io\Terminal\Terminal Io\Terminal\TerminalException -Io\Terminal\TerminalInterface Io\Terminal\TerminalSize RoundingMode SortDirection diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index e62386d8827d..d22502f37718 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -164,10 +164,10 @@ typedef struct php_io_terminal_stream_target { static zend_class_entry *php_io_terminal_exception_ce; static zend_class_entry *php_io_terminal_key_ce; static zend_class_entry *php_io_terminal_terminal_size_ce; -static zend_class_entry *php_io_terminal_mode_token_interface_ce; -static zend_class_entry *php_io_terminal_terminal_interface_ce; static zend_class_entry *php_io_terminal_mode_token_ce; static zend_class_entry *php_io_terminal_terminal_ce; +static zend_class_entry *php_io_terminal_system_mode_token_ce; +static zend_class_entry *php_io_terminal_system_terminal_ce; static zend_object_handlers php_io_terminal_mode_token_handlers; static zend_object_handlers php_io_terminal_object_handlers; ZEND_TLS php_io_terminal_shared_mode *php_io_terminal_active_shared_modes; @@ -624,7 +624,7 @@ static void php_io_terminal_create_mode_token(zval *return_value, php_io_termina { php_io_terminal_mode_token_object *intern; - object_init_ex(return_value, php_io_terminal_mode_token_ce); + object_init_ex(return_value, php_io_terminal_system_mode_token_ce); intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value); intern->shared = shared; intern->valid = true; @@ -721,6 +721,33 @@ static bool php_io_terminal_stream_target_init_ex( #else target->native_stream = is_input ? STDIN_FILENO : STDOUT_FILENO; #endif + if (is_input) { + zval *stdin_zv = zend_get_constant_str("STDIN", sizeof("STDIN") - 1); + if (stdin_zv != NULL && Z_TYPE_P(stdin_zv) == IS_RESOURCE) { + target->php_stream = (php_stream *) zend_fetch_resource2( + Z_RES_P(stdin_zv), + NULL, + php_file_le_stream(), + php_file_le_pstream() + ); + if (target->php_stream != NULL) { + target->stream_resource = stdin_zv; + } + } + } else { + zval *stdout_zv = zend_get_constant_str("STDOUT", sizeof("STDOUT") - 1); + if (stdout_zv != NULL && Z_TYPE_P(stdout_zv) == IS_RESOURCE) { + target->php_stream = (php_stream *) zend_fetch_resource2( + Z_RES_P(stdout_zv), + NULL, + php_file_le_stream(), + php_file_le_pstream() + ); + if (target->php_stream != NULL) { + target->stream_resource = stdout_zv; + } + } + } return true; } @@ -2176,23 +2203,23 @@ PHP_METHOD(Io_Terminal_TerminalSize, __construct) } } -/* Io\Terminal\ModeToken methods */ -PHP_METHOD(Io_Terminal_ModeToken, __construct) +/* Io\Terminal\SystemModeToken methods */ +PHP_METHOD(Io_Terminal_SystemModeToken, __construct) { - zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\ModeToken"); + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemModeToken"); } -/* Io\Terminal\Terminal methods */ -PHP_METHOD(Io_Terminal_Terminal, __construct) +/* Io\Terminal\SystemTerminal methods */ +PHP_METHOD(Io_Terminal_SystemTerminal, __construct) { - zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\Terminal"); + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemTerminal"); } -PHP_METHOD(Io_Terminal_Terminal, fromStdio) +PHP_METHOD(Io_Terminal_SystemTerminal, fromStdio) { ZEND_PARSE_PARAMETERS_NONE(); - object_init_ex(return_value, php_io_terminal_terminal_ce); + object_init_ex(return_value, php_io_terminal_system_terminal_ce); php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); php_io_terminal_stream_target target; @@ -2202,7 +2229,7 @@ PHP_METHOD(Io_Terminal_Terminal, fromStdio) } } -PHP_METHOD(Io_Terminal_Terminal, fromStreams) +PHP_METHOD(Io_Terminal_SystemTerminal, fromStreams) { zval *input_arg; zval *output_arg = NULL; @@ -2231,7 +2258,7 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) } } - object_init_ex(return_value, php_io_terminal_terminal_ce); + object_init_ex(return_value, php_io_terminal_system_terminal_ce); php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); ZVAL_COPY(&intern->input_stream_val, input_arg); @@ -2246,7 +2273,7 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) } } -PHP_METHOD(Io_Terminal_Terminal, getSize) +PHP_METHOD(Io_Terminal_SystemTerminal, getSize) { ZEND_PARSE_PARAMETERS_NONE(); @@ -2274,7 +2301,7 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) object_init_with_constructor(return_value, php_io_terminal_terminal_size_ce, 2, params, NULL); } -PHP_METHOD(Io_Terminal_Terminal, enableRawMode) +PHP_METHOD(Io_Terminal_SystemTerminal, enableRawMode) { ZEND_PARSE_PARAMETERS_NONE(); @@ -2329,19 +2356,19 @@ PHP_METHOD(Io_Terminal_Terminal, enableRawMode) * shared mode record; foreign userland token implementations must still be rejected * with ValueError. */ -PHP_METHOD(Io_Terminal_Terminal, restoreMode) +PHP_METHOD(Io_Terminal_SystemTerminal, restoreMode) { zval *mode_token = NULL; ZEND_PARSE_PARAMETERS_START(0, 1) Z_PARAM_OPTIONAL - Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_interface_ce) + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(mode_token, php_io_terminal_mode_token_ce) ZEND_PARSE_PARAMETERS_END(); php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { - if (!instanceof_function(Z_OBJCE_P(mode_token), php_io_terminal_mode_token_ce)) { + if (!instanceof_function(Z_OBJCE_P(mode_token), php_io_terminal_system_mode_token_ce)) { zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); RETURN_THROWS(); } @@ -2411,7 +2438,7 @@ PHP_METHOD(Io_Terminal_Terminal, restoreMode) RETURN_FALSE; } -PHP_METHOD(Io_Terminal_Terminal, readKey) +PHP_METHOD(Io_Terminal_SystemTerminal, readKey) { php_date_time_duration *timeout_duration = NULL; php_date_time_duration *seq_timeout_duration = NULL; @@ -2538,7 +2565,343 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) RETURN_STR(key); } -PHP_METHOD(Io_Terminal_Terminal, readSecret) +#ifdef PHP_WIN32 +static zend_string *php_io_terminal_read_line_windows( + php_io_terminal_native_stream handle, + php_stream *stream, + php_io_terminal_object *intern +) +{ + /* If there is already buffered data in php_stream, use php_stream_get_line */ + if (stream != NULL && stream->writepos > stream->readpos) { + size_t line_len = 0; + char *buf = php_stream_get_line(stream, NULL, 0, &line_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + return NULL; + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from stream", 0); + } + return NULL; + } + if (line_len >= 2 && buf[line_len - 2] == '\r' && buf[line_len - 1] == '\n') { + line_len -= 2; + } else if (line_len >= 1 && buf[line_len - 1] == '\n') { + line_len -= 1; + } + buf[line_len] = '\0'; + zend_string *res = zend_string_init(buf, line_len, 0); + efree(buf); + return res; + } + + DWORD original_mode = 0; + bool is_console = (handle != INVALID_HANDLE_VALUE && handle != NULL && GetConsoleMode(handle, &original_mode) != 0); + + if (!is_console) { + /* Redirected/non-console stream or handle */ + if (stream != NULL) { + size_t line_len = 0; + char *buf = php_stream_get_line(stream, NULL, 0, &line_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + return NULL; + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from stream", 0); + } + return NULL; + } + if (line_len >= 2 && buf[line_len - 2] == '\r' && buf[line_len - 1] == '\n') { + line_len -= 2; + } else if (line_len >= 1 && buf[line_len - 1] == '\n') { + line_len -= 1; + } + buf[line_len] = '\0'; + zend_string *res = zend_string_init(buf, line_len, 0); + efree(buf); + return res; + } + + /* Bare non-console handle */ + smart_str line = {0}; + char ch; + DWORD bytes_read; + while (1) { + if (!ReadFile(handle, &ch, 1, &bytes_read, NULL)) { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_HANDLE_EOF) { + break; /* EOF */ + } + smart_str_free(&line); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from handle", 0); + return NULL; + } + if (bytes_read == 0) { + break; /* EOF */ + } + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } + + if (line.s == NULL) { + return NULL; + } + + size_t len = ZSTR_LEN(line.s); + char *val = ZSTR_VAL(line.s); + if (len >= 2 && val[len - 2] == '\r' && val[len - 1] == '\n') { + len -= 2; + } else if (len >= 1 && val[len - 1] == '\n') { + len -= 1; + } + val[len] = '\0'; + zend_string *res = zend_string_init(val, len, 0); + smart_str_free(&line); + return res; + } + + /* Native Windows console input */ + DWORD cooked_mode = original_mode | (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT); + bool mode_changed = false; + if (cooked_mode != original_mode) { + if (!SetConsoleMode(handle, cooked_mode)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set console mode for line reading", 0); + return NULL; + } + mode_changed = true; + } + + size_t wcapacity = 1024; + size_t wlen = 0; + WCHAR *wline = emalloc(wcapacity * sizeof(WCHAR)); + bool read_failed = false; + + while (true) { + DWORD chars_read = 0; + BOOL ok = ReadConsoleW(handle, wline + wlen, (DWORD)(wcapacity - wlen), &chars_read, NULL); + if (!ok) { + read_failed = true; + break; + } + if (chars_read == 0) { + break; /* EOF */ + } + wlen += chars_read; + if (wline[wlen - 1] == L'\n' || wline[wlen - 1] == 0x1a) { + break; + } + if (wlen == wcapacity) { + wcapacity *= 2; + wline = erealloc(wline, wcapacity * sizeof(WCHAR)); + } + } + + if (mode_changed) { + SetConsoleMode(handle, original_mode); + } + + if (read_failed) { + efree(wline); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from console", 0); + return NULL; + } + + if (wlen == 0 || (wlen == 1 && wline[0] == 0x1a)) { + efree(wline); + return NULL; /* EOF */ + } + + /* Normalize CRLF / LF */ + if (wlen >= 2 && wline[wlen - 2] == L'\r' && wline[wlen - 1] == L'\n') { + wlen -= 2; + } else if (wlen >= 1 && (wline[wlen - 1] == L'\n' || wline[wlen - 1] == 0x1a)) { + wlen -= 1; + } else if (wlen >= 1 && wline[wlen - 1] == L'\r') { + wlen -= 1; + } + + if (wlen == 0) { + efree(wline); + return zend_string_init("", 0, 0); + } + + int utf8_len = WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, NULL, 0, NULL, NULL); + if (utf8_len < 0) { + efree(wline); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to convert console line to UTF-8", 0); + return NULL; + } + + zend_string *res = zend_string_alloc(utf8_len, 0); + WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, ZSTR_VAL(res), utf8_len, NULL, NULL); + ZSTR_VAL(res)[utf8_len] = '\0'; + efree(wline); + return res; +} +#else +static char *php_io_terminal_read_line_from_fd(int fd, size_t *out_len) +{ + smart_str line = {0}; + char ch; + errno = 0; + while (1) { + ssize_t n = read(fd, &ch, 1); + if (n > 0) { + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } else if (n == 0) { + break; /* EOF */ + } else { + if (errno == EINTR) { + continue; + } + smart_str_free(&line); + return NULL; + } + } + + if (line.s == NULL) { + *out_len = 0; + return NULL; + } + + *out_len = ZSTR_LEN(line.s); + char *res = estrndup(ZSTR_VAL(line.s), *out_len); + smart_str_free(&line); + return res; +} + +static char *php_io_terminal_read_line_posix( + php_io_terminal_native_stream fd, + php_stream *stream, + size_t *out_len, + bool *is_eof +) +{ + *is_eof = false; + *out_len = 0; + + if (stream != NULL) { + char *buf = php_stream_get_line(stream, NULL, 0, out_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + *is_eof = true; + return NULL; + } + /* Read error */ + return NULL; + } + return buf; + } + + if (fd >= 0) { + char *buf = php_io_terminal_read_line_from_fd(fd, out_len); + if (buf == NULL) { + if (errno == 0 || errno == EAGAIN) { + *is_eof = true; + } + return NULL; + } + return buf; + } + + return NULL; +} +#endif + +PHP_METHOD(Io_Terminal_SystemTerminal, readLine) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + + php_io_terminal_stream_target stream; + if (!php_io_terminal_stream_target_init(&intern->input_stream_val, true, &stream)) { + RETURN_THROWS(); + } + + if (!intern->has_identity && php_io_terminal_native_stream_is_valid(stream.native_stream)) { + intern->has_identity = php_io_terminal_get_identity(stream.native_stream, &intern->identity); + } + + if (intern->active_mode_token != NULL || (intern->has_identity && php_io_terminal_find_shared_mode(&intern->identity) != NULL)) { + zend_throw_exception(php_io_terminal_exception_ce, "Cannot read a line while raw mode is active for this terminal", 0); + RETURN_THROWS(); + } + +#ifdef PHP_WIN32 + zend_string *line = php_io_terminal_read_line_windows(stream.native_stream, stream.php_stream, intern); + if (EG(exception)) { + if (line != NULL) { + zend_string_release(line); + } + RETURN_THROWS(); + } + if (line == NULL) { + RETURN_NULL(); + } + RETURN_STR(line); +#else + size_t out_len = 0; + bool is_eof = false; + char *buf = php_io_terminal_read_line_posix(stream.native_stream, stream.php_stream, &out_len, &is_eof); + if (EG(exception)) { + if (buf != NULL) { + efree(buf); + } + RETURN_THROWS(); + } + + if (buf == NULL) { + if (is_eof) { + if (intern->pending_utf8.length > 0) { + zend_string *res = zend_string_init((char *) intern->pending_utf8.bytes, intern->pending_utf8.length, 0); + intern->pending_utf8.length = 0; + intern->pending_utf8.expected = 0; + RETURN_STR(res); + } + RETURN_NULL(); + } + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from terminal", 0); + } + RETURN_THROWS(); + } + + /* Strip line ending */ + if (out_len >= 2 && buf[out_len - 2] == '\r' && buf[out_len - 1] == '\n') { + out_len -= 2; + } else if (out_len >= 1 && buf[out_len - 1] == '\n') { + out_len -= 1; + } + buf[out_len] = '\0'; + + zend_string *res; + if (intern->pending_utf8.length > 0) { + size_t total_len = intern->pending_utf8.length + out_len; + res = zend_string_alloc(total_len, 0); + memcpy(ZSTR_VAL(res), intern->pending_utf8.bytes, intern->pending_utf8.length); + if (out_len > 0) { + memcpy(ZSTR_VAL(res) + intern->pending_utf8.length, buf, out_len); + } + ZSTR_VAL(res)[total_len] = '\0'; + intern->pending_utf8.length = 0; + intern->pending_utf8.expected = 0; + } else { + res = zend_string_init(buf, out_len, 0); + } + efree(buf); + RETURN_STR(res); +#endif +} + +PHP_METHOD(Io_Terminal_SystemTerminal, readSecret) { php_date_time_duration *timeout_duration = NULL; @@ -2633,18 +2996,18 @@ PHP_MINIT_FUNCTION(terminal) php_io_terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); - php_io_terminal_mode_token_interface_ce = register_class_Io_Terminal_ModeTokenInterface(); - php_io_terminal_terminal_interface_ce = register_class_Io_Terminal_TerminalInterface(); + php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(); + php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(); - php_io_terminal_mode_token_ce = register_class_Io_Terminal_ModeToken(php_io_terminal_mode_token_interface_ce); - php_io_terminal_mode_token_ce->create_object = php_io_terminal_mode_token_create_object; + php_io_terminal_system_mode_token_ce = register_class_Io_Terminal_SystemModeToken(php_io_terminal_mode_token_ce); + php_io_terminal_system_mode_token_ce->create_object = php_io_terminal_mode_token_create_object; memcpy(&php_io_terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_mode_token_handlers)); php_io_terminal_mode_token_handlers.offset = offsetof(php_io_terminal_mode_token_object, std); php_io_terminal_mode_token_handlers.free_obj = php_io_terminal_mode_token_free_obj; php_io_terminal_mode_token_handlers.clone_obj = NULL; - php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(php_io_terminal_terminal_interface_ce); - php_io_terminal_terminal_ce->create_object = php_io_terminal_create_object; + php_io_terminal_system_terminal_ce = register_class_Io_Terminal_SystemTerminal(php_io_terminal_terminal_ce); + php_io_terminal_system_terminal_ce->create_object = php_io_terminal_create_object; memcpy(&php_io_terminal_object_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_object_handlers)); php_io_terminal_object_handlers.offset = offsetof(php_io_terminal_object, std); php_io_terminal_object_handlers.free_obj = php_io_terminal_free_obj; diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index e85a24577563..1e56d7eef25d 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -49,29 +49,33 @@ enum Key public function __construct(int $cols, int $rows) {} } - interface ModeTokenInterface {} + interface ModeToken {} - interface TerminalInterface + interface Terminal { public function getSize(): ?TerminalSize; - public function enableRawMode(): ModeTokenInterface; + public function enableRawMode(): ModeToken; - public function restoreMode(?ModeTokenInterface $mode = null): bool; + public function restoreMode(?ModeToken $mode = null): bool; public function readKey( ?\Time\Duration $timeout = null, ?\Time\Duration $sequenceTimeout = null, ): Key|string|null; - public function readSecret(?\Time\Duration $timeout = null): ?string; + public function readLine(): ?string; + + public function readSecret( + ?\Time\Duration $timeout = null, + ): ?string; } /** * @strict-properties * @not-serializable */ - final class ModeToken implements ModeTokenInterface + final class SystemModeToken implements ModeToken { private function __construct() {} } @@ -80,29 +84,38 @@ private function __construct() {} * @strict-properties * @not-serializable */ - final class Terminal implements TerminalInterface + final class SystemTerminal implements Terminal { private function __construct() {} - public static function fromStdio(): Terminal {} + public static function fromStdio(): SystemTerminal {} /** * @param resource $input * @param resource|null $output */ - public static function fromStreams($input, $output = null): Terminal {} + public static function fromStreams( + $input, + $output = null, + ): SystemTerminal {} public function getSize(): ?TerminalSize {} - public function enableRawMode(): ModeToken {} + public function enableRawMode(): SystemModeToken {} - public function restoreMode(?ModeTokenInterface $mode = null): bool {} + public function restoreMode( + ?ModeToken $mode = null, + ): bool {} public function readKey( ?\Time\Duration $timeout = null, ?\Time\Duration $sequenceTimeout = null, ): Key|string|null {} - public function readSecret(?\Time\Duration $timeout = null): ?string {} + public function readLine(): ?string {} + + public function readSecret( + ?\Time\Duration $timeout = null, + ): ?string {} } } diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 47801aa1f343..cdd7b7dffb5f 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b + * Stub hash: c963fd0c25f8d397c4c3b1d47e16258e6201c8cf * Has decl header: yes */ #include "zend_enum.h" @@ -9,88 +9,96 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_TerminalSize___construct, 0, 0, ZEND_ARG_TYPE_INFO(0, rows, IS_LONG, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_getSize, 0, 0, Io\\Terminal\\TerminalSize, 1) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_getSize, 0, 0, Io\\Terminal\\TerminalSize, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_enableRawMode, 0, 0, Io\\Terminal\\ModeTokenInterface, 0) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_enableRawMode, 0, 0, Io\\Terminal\\ModeToken, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_restoreMode, 0, 0, _IS_BOOL, 0) - ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, mode, Io\\Terminal\\ModeTokenInterface, 1, "null") +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_restoreMode, 0, 0, _IS_BOOL, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, mode, Io\\Terminal\\ModeToken, 1, "null") ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_TerminalInterface_readKey, 0, 0, Io\\Terminal\\Key, MAY_BE_STRING|MAY_BE_NULL) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Io_Terminal_Terminal_readKey, 0, 0, Io\\Terminal\\Key, MAY_BE_STRING|MAY_BE_NULL) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, timeout, Time\\Duration, 1, "null") ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, sequenceTimeout, Time\\Duration, 1, "null") ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_TerminalInterface_readSecret, 0, 0, IS_STRING, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_readLine, 0, 0, IS_STRING, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_readSecret, 0, 0, IS_STRING, 1) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, timeout, Time\\Duration, 1, "null") ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_ModeToken___construct, 0, 0, 0) +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_SystemModeToken___construct, 0, 0, 0) ZEND_END_ARG_INFO() -#define arginfo_class_Io_Terminal_Terminal___construct arginfo_class_Io_Terminal_ModeToken___construct +#define arginfo_class_Io_Terminal_SystemTerminal___construct arginfo_class_Io_Terminal_SystemModeToken___construct -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_fromStdio, 0, 0, Io\\Terminal\\Terminal, 0) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_SystemTerminal_fromStdio, 0, 0, Io\\Terminal\\SystemTerminal, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_fromStreams, 0, 1, Io\\Terminal\\Terminal, 0) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_SystemTerminal_fromStreams, 0, 1, Io\\Terminal\\SystemTerminal, 0) ZEND_ARG_INFO(0, input) ZEND_ARG_INFO_WITH_DEFAULT_VALUE(0, output, "null") ZEND_END_ARG_INFO() -#define arginfo_class_Io_Terminal_Terminal_getSize arginfo_class_Io_Terminal_TerminalInterface_getSize +#define arginfo_class_Io_Terminal_SystemTerminal_getSize arginfo_class_Io_Terminal_Terminal_getSize -ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_enableRawMode, 0, 0, Io\\Terminal\\ModeToken, 0) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_SystemTerminal_enableRawMode, 0, 0, Io\\Terminal\\SystemModeToken, 0) ZEND_END_ARG_INFO() -#define arginfo_class_Io_Terminal_Terminal_restoreMode arginfo_class_Io_Terminal_TerminalInterface_restoreMode +#define arginfo_class_Io_Terminal_SystemTerminal_restoreMode arginfo_class_Io_Terminal_Terminal_restoreMode + +#define arginfo_class_Io_Terminal_SystemTerminal_readKey arginfo_class_Io_Terminal_Terminal_readKey -#define arginfo_class_Io_Terminal_Terminal_readKey arginfo_class_Io_Terminal_TerminalInterface_readKey +#define arginfo_class_Io_Terminal_SystemTerminal_readLine arginfo_class_Io_Terminal_Terminal_readLine -#define arginfo_class_Io_Terminal_Terminal_readSecret arginfo_class_Io_Terminal_TerminalInterface_readSecret +#define arginfo_class_Io_Terminal_SystemTerminal_readSecret arginfo_class_Io_Terminal_Terminal_readSecret ZEND_METHOD(Io_Terminal_TerminalSize, __construct); -ZEND_METHOD(Io_Terminal_ModeToken, __construct); -ZEND_METHOD(Io_Terminal_Terminal, __construct); -ZEND_METHOD(Io_Terminal_Terminal, fromStdio); -ZEND_METHOD(Io_Terminal_Terminal, fromStreams); -ZEND_METHOD(Io_Terminal_Terminal, getSize); -ZEND_METHOD(Io_Terminal_Terminal, enableRawMode); -ZEND_METHOD(Io_Terminal_Terminal, restoreMode); -ZEND_METHOD(Io_Terminal_Terminal, readKey); -ZEND_METHOD(Io_Terminal_Terminal, readSecret); +ZEND_METHOD(Io_Terminal_SystemModeToken, __construct); +ZEND_METHOD(Io_Terminal_SystemTerminal, __construct); +ZEND_METHOD(Io_Terminal_SystemTerminal, fromStdio); +ZEND_METHOD(Io_Terminal_SystemTerminal, fromStreams); +ZEND_METHOD(Io_Terminal_SystemTerminal, getSize); +ZEND_METHOD(Io_Terminal_SystemTerminal, enableRawMode); +ZEND_METHOD(Io_Terminal_SystemTerminal, restoreMode); +ZEND_METHOD(Io_Terminal_SystemTerminal, readKey); +ZEND_METHOD(Io_Terminal_SystemTerminal, readLine); +ZEND_METHOD(Io_Terminal_SystemTerminal, readSecret); static const zend_function_entry class_Io_Terminal_TerminalSize_methods[] = { ZEND_ME(Io_Terminal_TerminalSize, __construct, arginfo_class_Io_Terminal_TerminalSize___construct, ZEND_ACC_PUBLIC) ZEND_FE_END }; -static const zend_function_entry class_Io_Terminal_TerminalInterface_methods[] = { - ZEND_RAW_FENTRY("getSize", NULL, arginfo_class_Io_Terminal_TerminalInterface_getSize, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("enableRawMode", NULL, arginfo_class_Io_Terminal_TerminalInterface_enableRawMode, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("restoreMode", NULL, arginfo_class_Io_Terminal_TerminalInterface_restoreMode, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("readKey", NULL, arginfo_class_Io_Terminal_TerminalInterface_readKey, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("readSecret", NULL, arginfo_class_Io_Terminal_TerminalInterface_readSecret, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) +static const zend_function_entry class_Io_Terminal_Terminal_methods[] = { + ZEND_RAW_FENTRY("getSize", NULL, arginfo_class_Io_Terminal_Terminal_getSize, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("enableRawMode", NULL, arginfo_class_Io_Terminal_Terminal_enableRawMode, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("restoreMode", NULL, arginfo_class_Io_Terminal_Terminal_restoreMode, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("readKey", NULL, arginfo_class_Io_Terminal_Terminal_readKey, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("readLine", NULL, arginfo_class_Io_Terminal_Terminal_readLine, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("readSecret", NULL, arginfo_class_Io_Terminal_Terminal_readSecret, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_FE_END }; -static const zend_function_entry class_Io_Terminal_ModeToken_methods[] = { - ZEND_ME(Io_Terminal_ModeToken, __construct, arginfo_class_Io_Terminal_ModeToken___construct, ZEND_ACC_PRIVATE) +static const zend_function_entry class_Io_Terminal_SystemModeToken_methods[] = { + ZEND_ME(Io_Terminal_SystemModeToken, __construct, arginfo_class_Io_Terminal_SystemModeToken___construct, ZEND_ACC_PRIVATE) ZEND_FE_END }; -static const zend_function_entry class_Io_Terminal_Terminal_methods[] = { - ZEND_ME(Io_Terminal_Terminal, __construct, arginfo_class_Io_Terminal_Terminal___construct, ZEND_ACC_PRIVATE) - ZEND_ME(Io_Terminal_Terminal, fromStdio, arginfo_class_Io_Terminal_Terminal_fromStdio, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) - ZEND_ME(Io_Terminal_Terminal, fromStreams, arginfo_class_Io_Terminal_Terminal_fromStreams, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) - ZEND_ME(Io_Terminal_Terminal, getSize, arginfo_class_Io_Terminal_Terminal_getSize, ZEND_ACC_PUBLIC) - ZEND_ME(Io_Terminal_Terminal, enableRawMode, arginfo_class_Io_Terminal_Terminal_enableRawMode, ZEND_ACC_PUBLIC) - ZEND_ME(Io_Terminal_Terminal, restoreMode, arginfo_class_Io_Terminal_Terminal_restoreMode, ZEND_ACC_PUBLIC) - ZEND_ME(Io_Terminal_Terminal, readKey, arginfo_class_Io_Terminal_Terminal_readKey, ZEND_ACC_PUBLIC) - ZEND_ME(Io_Terminal_Terminal, readSecret, arginfo_class_Io_Terminal_Terminal_readSecret, ZEND_ACC_PUBLIC) +static const zend_function_entry class_Io_Terminal_SystemTerminal_methods[] = { + ZEND_ME(Io_Terminal_SystemTerminal, __construct, arginfo_class_Io_Terminal_SystemTerminal___construct, ZEND_ACC_PRIVATE) + ZEND_ME(Io_Terminal_SystemTerminal, fromStdio, arginfo_class_Io_Terminal_SystemTerminal_fromStdio, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Io_Terminal_SystemTerminal, fromStreams, arginfo_class_Io_Terminal_SystemTerminal_fromStreams, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Io_Terminal_SystemTerminal, getSize, arginfo_class_Io_Terminal_SystemTerminal_getSize, ZEND_ACC_PUBLIC) + ZEND_ME(Io_Terminal_SystemTerminal, enableRawMode, arginfo_class_Io_Terminal_SystemTerminal_enableRawMode, ZEND_ACC_PUBLIC) + ZEND_ME(Io_Terminal_SystemTerminal, restoreMode, arginfo_class_Io_Terminal_SystemTerminal_restoreMode, ZEND_ACC_PUBLIC) + ZEND_ME(Io_Terminal_SystemTerminal, readKey, arginfo_class_Io_Terminal_SystemTerminal_readKey, ZEND_ACC_PUBLIC) + ZEND_ME(Io_Terminal_SystemTerminal, readLine, arginfo_class_Io_Terminal_SystemTerminal_readLine, ZEND_ACC_PUBLIC) + ZEND_ME(Io_Terminal_SystemTerminal, readSecret, arginfo_class_Io_Terminal_SystemTerminal_readSecret, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -185,44 +193,44 @@ static zend_class_entry *register_class_Io_Terminal_TerminalSize(void) return class_entry; } -static zend_class_entry *register_class_Io_Terminal_ModeTokenInterface(void) +static zend_class_entry *register_class_Io_Terminal_ModeToken(void) { zend_class_entry ce, *class_entry; - INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeTokenInterface", NULL); + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeToken", NULL); class_entry = zend_register_internal_interface(&ce); return class_entry; } -static zend_class_entry *register_class_Io_Terminal_TerminalInterface(void) +static zend_class_entry *register_class_Io_Terminal_Terminal(void) { zend_class_entry ce, *class_entry; - INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "TerminalInterface", class_Io_Terminal_TerminalInterface_methods); + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "Terminal", class_Io_Terminal_Terminal_methods); class_entry = zend_register_internal_interface(&ce); return class_entry; } -static zend_class_entry *register_class_Io_Terminal_ModeToken(zend_class_entry *class_entry_Io_Terminal_ModeTokenInterface) +static zend_class_entry *register_class_Io_Terminal_SystemModeToken(zend_class_entry *class_entry_Io_Terminal_ModeToken) { zend_class_entry ce, *class_entry; - INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeToken", class_Io_Terminal_ModeToken_methods); + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "SystemModeToken", class_Io_Terminal_SystemModeToken_methods); class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); - zend_class_implements(class_entry, 1, class_entry_Io_Terminal_ModeTokenInterface); + zend_class_implements(class_entry, 1, class_entry_Io_Terminal_ModeToken); return class_entry; } -static zend_class_entry *register_class_Io_Terminal_Terminal(zend_class_entry *class_entry_Io_Terminal_TerminalInterface) +static zend_class_entry *register_class_Io_Terminal_SystemTerminal(zend_class_entry *class_entry_Io_Terminal_Terminal) { zend_class_entry ce, *class_entry; - INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "Terminal", class_Io_Terminal_Terminal_methods); + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "SystemTerminal", class_Io_Terminal_SystemTerminal_methods); class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); - zend_class_implements(class_entry, 1, class_entry_Io_Terminal_TerminalInterface); + zend_class_implements(class_entry, 1, class_entry_Io_Terminal_Terminal); return class_entry; } diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index c864f22b91b5..eee8aa91fb2e 100644 --- a/ext/standard/io_terminal_decl.h +++ b/ext/standard/io_terminal_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 08c0a2c007b6991ce30979c5727ed83cf1728c8b */ + * Stub hash: c963fd0c25f8d397c4c3b1d47e16258e6201c8cf */ -#ifndef ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H -#define ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H +#ifndef ZEND_IO_TERMINAL_DECL_c963fd0c25f8d397c4c3b1d47e16258e6201c8cf_H +#define ZEND_IO_TERMINAL_DECL_c963fd0c25f8d397c4c3b1d47e16258e6201c8cf_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -33,4 +33,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 26, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_08c0a2c007b6991ce30979c5727ed83cf1728c8b_H */ +#endif /* ZEND_IO_TERMINAL_DECL_c963fd0c25f8d397c4c3b1d47e16258e6201c8cf_H */ diff --git a/ext/standard/tests/terminal/terminal_controlling_tty.phpt b/ext/standard/tests/terminal/terminal_controlling_tty.phpt index 236256dd0823..f1627b53cfdf 100644 --- a/ext/standard/tests/terminal/terminal_controlling_tty.phpt +++ b/ext/standard/tests/terminal/terminal_controlling_tty.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: /dev/tty controlling terminal identity +Io\Terminal\SystemTerminal: /dev/tty controlling terminal identity --SKIPIF-- enableRawMode(); var_dump($mTty instanceof ModeToken); diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index 9ddf03b35ad9..c211fc9ea803 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -1,39 +1,39 @@ --TEST-- -Io\Terminal\Terminal: fromStdio, fromStreams, construction and serialization restrictions +Io\Terminal\SystemTerminal: fromStdio, fromStreams, construction and serialization restrictions --FILE-- getMessage(), PHP_EOL; } try { - Terminal::fromStreams($fp, 123); + SystemTerminal::fromStreams($fp, 123); } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Constructor privacy for Terminal and ModeToken -foreach ([Terminal::class, ModeToken::class] as $class) { +// Constructor privacy for SystemTerminal and SystemModeToken +foreach ([SystemTerminal::class, SystemModeToken::class] as $class) { try { new $class(); } catch (Throwable $e) { @@ -79,7 +79,7 @@ foreach ([$t1, $t2] as $obj) { } } -// Serialization rejection for Terminal +// Serialization rejection for SystemTerminal try { serialize($t1); } catch (Throwable $e) { @@ -88,16 +88,16 @@ try { ?> --EXPECT-- -object(Io\Terminal\Terminal)#1 (0) { +object(Io\Terminal\SystemTerminal)#1 (0) { } -object(Io\Terminal\Terminal)#2 (0) { +object(Io\Terminal\SystemTerminal)#2 (0) { } -object(Io\Terminal\Terminal)#3 (0) { +object(Io\Terminal\SystemTerminal)#3 (0) { } -TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given -TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given -Error: Call to private Io\Terminal\Terminal::__construct() from global scope -Error: Call to private Io\Terminal\ModeToken::__construct() from global scope +TypeError: Io\Terminal\SystemTerminal::fromStreams(): Argument #1 ($input) must be of type resource, string given +TypeError: Io\Terminal\SystemTerminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given +Error: Call to private Io\Terminal\SystemTerminal::__construct() from global scope +Error: Call to private Io\Terminal\SystemModeToken::__construct() from global scope int(80) int(24) ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0 @@ -108,6 +108,6 @@ Error: Cannot modify readonly property Io\Terminal\TerminalSize::$cols bool(true) int(80) int(24) -Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal -Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal -Exception: Serialization of 'Io\Terminal\Terminal' is not allowed +Error: Trying to clone an uncloneable object of class Io\Terminal\SystemTerminal +Error: Trying to clone an uncloneable object of class Io\Terminal\SystemTerminal +Exception: Serialization of 'Io\Terminal\SystemTerminal' is not allowed diff --git a/ext/standard/tests/terminal/terminal_identity.phpt b/ext/standard/tests/terminal/terminal_identity.phpt index 9f64ea9c9923..ce8c8f75c681 100644 --- a/ext/standard/tests/terminal/terminal_identity.phpt +++ b/ext/standard/tests/terminal/terminal_identity.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: POSIX terminal identity and unrelated PTY rejection +Io\Terminal\SystemTerminal: POSIX terminal identity and unrelated PTY rejection --SKIPIF-- enableRawMode(); var_dump($m1_dup instanceof ModeToken); @@ -92,7 +92,7 @@ $proc3 = proc_open( ], $pipes3, ); -$t3 = Terminal::fromStreams($pipes3[0]); +$t3 = SystemTerminal::fromStreams($pipes3[0]); try { $t3->restoreMode($m_reuse); echo "FAIL: reused fd accepted token\n"; @@ -115,7 +115,7 @@ proc_close($proc3); --EXPECT-- bool(true) bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(false) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_interface_mock.phpt b/ext/standard/tests/terminal/terminal_interface_mock.phpt index d6dd44f0a738..b3b4521c58ac 100644 --- a/ext/standard/tests/terminal/terminal_interface_mock.phpt +++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt @@ -1,27 +1,27 @@ --TEST-- -Io\Terminal\TerminalInterface: mockable terminal boundary and foreign token rejection +Io\Terminal\Terminal: mockable terminal boundary and foreign token rejection --FILE-- implementsInterface(TerminalInterface::class)); +$rcTerm = new ReflectionClass(SystemTerminal::class); +var_dump($rcTerm->implementsInterface(Terminal::class)); -$rcToken = new ReflectionClass(ModeToken::class); -var_dump($rcToken->implementsInterface(ModeTokenInterface::class)); +$rcToken = new ReflectionClass(SystemModeToken::class); +var_dump($rcToken->implementsInterface(ModeToken::class)); // Application-level fake -class FakeModeToken implements ModeTokenInterface {} +class FakeModeToken implements ModeToken {} -class FakeTerminal implements TerminalInterface +class FakeTerminal implements Terminal { public bool $isRaw = false; @@ -30,13 +30,13 @@ class FakeTerminal implements TerminalInterface return new TerminalSize(120, 40); } - public function enableRawMode(): ModeTokenInterface + public function enableRawMode(): ModeToken { $this->isRaw = true; return new FakeModeToken(); } - public function restoreMode(?ModeTokenInterface $mode = null): bool + public function restoreMode(?ModeToken $mode = null): bool { $this->isRaw = false; return true; @@ -49,14 +49,19 @@ class FakeTerminal implements TerminalInterface return Key::Up; } + public function readLine(): ?string + { + return "mocked-line"; + } + public function readSecret(?Duration $timeout = null): ?string { return "mocked-secret"; } } -// Application service consuming TerminalInterface -function promptPassword(TerminalInterface $term): string +// Application service consuming Terminal +function promptPassword(Terminal $term): string { $size = $term->getSize(); $token = $term->enableRawMode(); @@ -72,13 +77,13 @@ $fake = new FakeTerminal(); echo promptPassword($fake), PHP_EOL; var_dump($fake->isRaw); -// Native restoreMode rejects foreign ModeTokenInterface implementations +// Native restoreMode rejects foreign ModeToken implementations $fp = fopen('php://temp', 'r+'); -$native = Terminal::fromStreams($fp); +$native = SystemTerminal::fromStreams($fp); try { $native->restoreMode(new FakeModeToken()); - echo "FAIL: native accepted foreign ModeTokenInterface\n"; + echo "FAIL: native accepted foreign ModeToken\n"; } catch (ValueError $e) { echo "Caught: ", $e->getMessage(), PHP_EOL; } @@ -89,4 +94,4 @@ bool(true) bool(true) size=120x40 secret=mocked-secret bool(false) -Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +Caught: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt index e9f72b100580..3a2cd94cd5d6 100644 --- a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: overlapping raw-mode sessions, ownership validation, and out-of-order restoration +Io\Terminal\SystemTerminal: overlapping raw-mode sessions, ownership validation, and out-of-order restoration --SKIPIF-- enableRawMode(); @@ -80,7 +80,7 @@ try { var_dump($t1->restoreMode($m1)); // Test 4: Token survives Terminal destruction when held externally -$tTemp = Terminal::fromStreams($pipes[0]); +$tTemp = SystemTerminal::fromStreams($pipes[0]); $mTemp = $tTemp->enableRawMode(); unset($tTemp); // Drops Terminal reference, but $mTemp remains active @@ -116,13 +116,13 @@ var_dump($t2->restoreMode($m2)); var_dump($t1->restoreMode($m1)); // Test 7: Destroying the final externally-held token restores canonical mode -$tLive = Terminal::fromStreams($pipes[0]); +$tLive = SystemTerminal::fromStreams($pipes[0]); $mHeld = $tLive->enableRawMode(); unset($tLive); // Terminal dropped; $mHeld keeps raw mode active unset($mHeld); // Final token destroyed; ModeToken destructor restores canonical mode // Terminal is back in canonical mode; further restore returns false -$tVerify = Terminal::fromStreams($pipes[0]); +$tVerify = SystemTerminal::fromStreams($pipes[0]); var_dump($tVerify->restoreMode()); fwrite($pipes[0], "exit\n"); @@ -141,10 +141,10 @@ proc_close($proc2); bool(true) bool(true) bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) bool(true) diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt index c9bce6519bc8..ed51fb1f4fff 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: enableRawMode, restoreMode, session mode tracking and stale token protection +Io\Terminal\SystemTerminal: enableRawMode, restoreMode, session mode tracking and stale token protection --SKIPIF-- enableRawMode(); @@ -69,12 +69,12 @@ var_dump($terminal->restoreMode()); // Restores active token var_dump($terminal->restoreMode()); // Already restored -> returns false // Auto-restoration on Terminal object destruction when token is unreferenced -$terminal2 = Terminal::fromStreams($pipes[0]); +$terminal2 = SystemTerminal::fromStreams($pipes[0]); $terminal2->enableRawMode(); unset($terminal2); // Terminal destructor drops reference; ModeToken destructor restores mode // Token survives Terminal object destruction when held externally -$terminal3 = Terminal::fromStreams($pipes[0]); +$terminal3 = SystemTerminal::fromStreams($pipes[0]); $token3 = $terminal3->enableRawMode(); var_dump($token3 instanceof ModeToken); unset($terminal3); // Drops Terminal reference, but $token3 is held externally @@ -101,7 +101,7 @@ $proc2 = proc_open( ], $pipes2, ); -$terminalClosed = Terminal::fromStreams($pipes2[0]); +$terminalClosed = SystemTerminal::fromStreams($pipes2[0]); $tokenClosed = $terminalClosed->enableRawMode(); var_dump($tokenClosed instanceof ModeToken); @@ -138,14 +138,14 @@ proc_close($proc); --EXPECT-- enableRawMode: ModeToken bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) bool(false) bool(true) bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) -ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(false) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt index f87c52a939bc..b73108a0a465 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -1,12 +1,12 @@ --TEST-- -Io\Terminal\Terminal: enableRawMode throws TerminalException for non-terminal streams +Io\Terminal\SystemTerminal: enableRawMode throws TerminalException for non-terminal streams --FILE-- enableRawMode(); diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt index 46d13f1746f1..84f5933e45e9 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -1,12 +1,12 @@ --TEST-- -Io\Terminal\Terminal: readKey with Time\Duration timeouts and parameter validation +Io\Terminal\SystemTerminal: readKey with Time\Duration timeouts and parameter validation --FILE-- readKey(Duration::fromSeconds(0)); echo "FAIL: readKey on non-terminal did not throw\n"; @@ -35,6 +35,6 @@ try { ?> --EXPECT-- -ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative -ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt index 4d9e5e27999f..ddea9474ae05 100644 --- a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: readKey null vs TerminalException contracts on POSIX PTY +Io\Terminal\SystemTerminal: readKey null vs TerminalException contracts on POSIX PTY --SKIPIF-- enableRawMode(); // Contract 1: No input + zero timeout (non-blocking) returns null immediately @@ -81,7 +81,7 @@ try { // Contract 4: Non-terminal input stream throws TerminalException $fp = fopen('php://temp', 'r+'); -$nonTty = Terminal::fromStreams($fp); +$nonTty = SystemTerminal::fromStreams($fp); try { $nonTty->readKey(Duration::fromSeconds(0)); echo "FAIL: non-terminal did not throw\n"; @@ -112,7 +112,7 @@ bool(true) bool(true) bool(true) bool(true) -ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative -ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\SystemTerminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative Io\Terminal\TerminalException: Failed to read key: input stream is not a terminal Io\Terminal\TerminalException: %s terminal input stream diff --git a/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt index 64a4b04f9762..5bb89bed0cf2 100644 --- a/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt @@ -33,7 +33,7 @@ proc_close($proc); enableRawMode(); fwrite($pipes1[0], "START\n"); @@ -122,7 +122,7 @@ $proc2 = proc_open( ); $r0 = fgets($pipes2[2]); -$terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); $terminal2->enableRawMode(); fwrite($pipes2[0], "START\n"); diff --git a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt index bae544899da7..31c8ecd783a9 100644 --- a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: readKey preserves unsupported CSI sequences +Io\Terminal\SystemTerminal: readKey preserves unsupported CSI sequences --SKIPIF-- enableRawMode(); $timeout = Duration::fromSeconds(1); diff --git a/ext/standard/tests/terminal/terminal_read_key_timeout.phpt b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt index b2545696624c..9ad908655854 100644 --- a/ext/standard/tests/terminal/terminal_read_key_timeout.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt @@ -33,7 +33,7 @@ proc_close($proc); enableRawMode(); // Signal child to write lone escape diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt index 7941e35c233f..e81bb87879b2 100644 --- a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt @@ -32,7 +32,7 @@ proc_close($proc); --FILE-- enableRawMode(); fwrite($pipes1[0], "START\n"); @@ -117,7 +117,7 @@ $proc2 = proc_open( ); $r0 = fgets($pipes2[2]); -$terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); $terminal2->enableRawMode(); fwrite($pipes2[0], "START\n"); @@ -173,7 +173,7 @@ $proc3 = proc_open( ); $r0 = fgets($pipes3[2]); -$terminal3 = Terminal::fromStreams($pipes3[0]); +$terminal3 = SystemTerminal::fromStreams($pipes3[0]); $terminal3->enableRawMode(); fwrite($pipes3[0], "START\n"); diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt index 5e53328eee06..489b43bd4cee 100644 --- a/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt @@ -32,7 +32,7 @@ proc_close($proc); --FILE-- enableRawMode(); return [$proc, $pipes, $terminal]; } diff --git a/ext/standard/tests/terminal/terminal_read_line.phpt b/ext/standard/tests/terminal/terminal_read_line.phpt new file mode 100644 index 000000000000..c3278160ce29 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line.phpt @@ -0,0 +1,148 @@ +--TEST-- +Io\Terminal\SystemTerminal: readLine line ending, whitespace, buffering, and EOF stream contracts +--FILE-- +getNumberOfParameters()); +var_dump((string) $reflection->getReturnType()); + +// 1. Argument validation: readLine takes 0 parameters +$fp = fopen('php://temp', 'r+'); +$terminal = SystemTerminal::fromStreams($fp); +try { + $terminal->readLine('extra'); + echo "FAIL: readLine accepted argument\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +// 2. Immediate EOF returns null +var_dump($terminal->readLine()); + +// 3. Ordinary completed line ("hello\n") +fwrite($fp, "hello\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 4. CRLF line ending ("world\r\n") +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "world\r\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 5. Empty line ("\n" and "\r\n") +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "\n\r\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 6. Leading and trailing whitespace preserved +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, " spaced line \n"); +rewind($fp); +var_dump($terminal->readLine()); + +// 7. Tabs preserved +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "\t\tindented with tabs\t\t\r\n"); +rewind($fp); +var_dump($terminal->readLine()); + +// 8. Unterminated final line at EOF +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "final unterminated"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 9. Multiple sequential lines +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "alpha\nbeta\r\ngamma\ndelta"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 10. Unicode line behavior +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "こんにちは世界\nCafé au lait\r\n🦀 Rust & 🐘 PHP\n"); +rewind($fp); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 11. Already-buffered php_stream input is not lost +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "buffer line 1\nbuffer line 2\n"); +rewind($fp); +// Read single character via standard PHP stream function to populate php_stream internal buffer +$c = fgetc($fp); +var_dump($c); +// SystemTerminal::readLine must consume from php_stream buffer, not bypass it +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +fclose($fp); + +// 12. Memory stream (php://memory) +$mem = fopen('php://memory', 'r+'); +fwrite($mem, "memory line\n"); +rewind($mem); +$tMem = SystemTerminal::fromStreams($mem); +var_dump($tMem->readLine()); +fclose($mem); + +?> +--EXPECT-- +bool(true) +int(0) +string(7) "?string" +ArgumentCountError: Io\Terminal\SystemTerminal::readLine() expects exactly 0 arguments, 1 given +NULL +string(5) "hello" +NULL +string(5) "world" +NULL +string(0) "" +string(0) "" +NULL +string(17) " spaced line " +string(22) " indented with tabs " +string(18) "final unterminated" +NULL +string(5) "alpha" +string(4) "beta" +string(5) "gamma" +string(5) "delta" +NULL +string(21) "こんにちは世界" +string(13) "Café au lait" +string(20) "🦀 Rust & 🐘 PHP" +NULL +string(1) "b" +string(12) "uffer line 1" +string(13) "buffer line 2" +NULL +string(11) "memory line" diff --git a/ext/standard/tests/terminal/terminal_read_line_lease.phpt b/ext/standard/tests/terminal/terminal_read_line_lease.phpt new file mode 100644 index 000000000000..38369019aea2 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt @@ -0,0 +1,151 @@ +--TEST-- +Io\Terminal\SystemTerminal: readLine raw-mode lease conflict, cross-instance validation, and PTY line contracts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, +); + +fgets($pipes[2]); // Wait for STARTED + +$t1 = SystemTerminal::fromStreams($pipes[1]); +$t2 = SystemTerminal::fromStreams($pipes[1]); + +// 1. Same-instance active raw lease throws TerminalException +$token = $t1->enableRawMode(); +try { + $t1->readLine(); + echo "FAIL: t1->readLine() succeeded while raw mode is active\n"; +} catch (TerminalException $e) { + echo "Same-instance: ", $e->getMessage(), PHP_EOL; +} + +// 2. Cross-instance active raw lease on same terminal device throws TerminalException +try { + $t2->readLine(); + echo "FAIL: t2->readLine() succeeded while raw mode is active on t1\n"; +} catch (TerminalException $e) { + echo "Cross-instance: ", $e->getMessage(), PHP_EOL; +} + +// 3. Terminal state remains usable after failure and restoration +$restored = $t1->restoreMode($token); +var_dump($restored); + +// 4. readLine works normally after raw mode is restored +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY1 +var_dump($t1->readLine()); + +// 5. Subsequent readLine calls continue to work normally +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY2 +var_dump($t2->readLine()); + +// 6. Fragmented UTF-8 prepending: pending UTF-8 bytes from readKey are prepended to readLine +$token2 = $t1->enableRawMode(); +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY3 +$key = $t1->readKey(Duration::fromMilliseconds(25)); +var_dump($key === null); // Incomplete, retained in pending_utf8 + +// Restore raw mode so readLine can proceed +$t1->restoreMode($token2); + +// Provide continuation byte \xa9 followed by remainder of line +fwrite($pipes[0], "GO\n"); +fgets($pipes[2]); // Wait for READY4 +$line = $t1->readLine(); +var_dump($line); + +// 7. Immediate EOF on PTY returns null +fwrite($pipes[0], "DONE\n"); +usleep(50000); // Allow child process to finish +var_dump($t1->readLine()); + +unset($t1, $t2); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); + +?> +--EXPECT-- +Same-instance: Cannot read a line while raw mode is active for this terminal +Cross-instance: Cannot read a line while raw mode is active for this terminal +bool(true) +string(14) "canonical line" +string(11) "second line" +bool(true) +string(7) "éclair" +NULL diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt index c73ab05d7040..0cf9efbbf9e6 100644 --- a/ext/standard/tests/terminal/terminal_read_secret.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -1,16 +1,16 @@ --TEST-- -Io\Terminal\Terminal: readSecret parameter validation and non-tty failure handling +Io\Terminal\SystemTerminal: readSecret parameter validation and non-tty failure handling --FILE-- readSecret(); @@ -75,7 +75,7 @@ $proc2 = proc_open( $pipes2, ); -$terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); $secret2 = $terminal2->readSecret(); echo "Fragmented UTF-8: ", bin2hex($secret2), "\n"; @@ -98,7 +98,7 @@ $proc3 = proc_open( $pipes3, ); -$terminal3 = Terminal::fromStreams($pipes3[0]); +$terminal3 = SystemTerminal::fromStreams($pipes3[0]); try { $terminal3->readSecret(); echo "FAIL: expected exception on disconnect\n"; @@ -124,7 +124,7 @@ $proc4 = proc_open( $pipes4, ); -$terminal4 = Terminal::fromStreams($pipes4[0]); +$terminal4 = SystemTerminal::fromStreams($pipes4[0]); try { $terminal4->readSecret(); echo "FAIL: expected abort on escape+Ctrl+C\n"; @@ -151,7 +151,7 @@ $proc5 = proc_open( $pipes5, ); -$terminal5 = Terminal::fromStreams($pipes5[0]); +$terminal5 = SystemTerminal::fromStreams($pipes5[0]); try { $terminal5->readSecret(); echo "FAIL: expected abort on escape+Enter\n"; @@ -177,7 +177,7 @@ $proc6 = proc_open( ], $pipes6, ); -$terminal6 = Terminal::fromStreams($pipes6[0]); +$terminal6 = SystemTerminal::fromStreams($pipes6[0]); try { $terminal6->readSecret(); echo "FAIL: expected abort on CSI+Ctrl+C\n"; @@ -202,7 +202,7 @@ $proc7 = proc_open( ], $pipes7, ); -$terminal7 = Terminal::fromStreams($pipes7[0]); +$terminal7 = SystemTerminal::fromStreams($pipes7[0]); try { $terminal7->readSecret(); echo "FAIL: expected abort on CSI+Ctrl+D\n"; @@ -227,7 +227,7 @@ $proc8 = proc_open( ], $pipes8, ); -$terminal8 = Terminal::fromStreams($pipes8[0]); +$terminal8 = SystemTerminal::fromStreams($pipes8[0]); try { $terminal8->readSecret(); echo "FAIL: expected abort on SS3+Ctrl+C\n"; @@ -252,7 +252,7 @@ $proc9 = proc_open( ], $pipes9, ); -$terminal9 = Terminal::fromStreams($pipes9[0]); +$terminal9 = SystemTerminal::fromStreams($pipes9[0]); $secret9 = $terminal9->readSecret(); echo "Valid CSI Up ignored: ", bin2hex($secret9), "\n"; fwrite($pipes9[0], "done\n"); @@ -273,7 +273,7 @@ $proc10 = proc_open( ], $pipes10, ); -$terminal10 = Terminal::fromStreams($pipes10[0]); +$terminal10 = SystemTerminal::fromStreams($pipes10[0]); $secret10 = $terminal10->readSecret(); echo "Valid SS3 F1 ignored: ", bin2hex($secret10), "\n"; fwrite($pipes10[0], "done\n"); @@ -294,7 +294,7 @@ $proc11 = proc_open( ], $pipes11, ); -$terminal11 = Terminal::fromStreams($pipes11[0]); +$terminal11 = SystemTerminal::fromStreams($pipes11[0]); $secret11 = $terminal11->readSecret(); echo "Internal timeout continue: ", bin2hex($secret11), "\n"; fwrite($pipes11[0], "done\n"); diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt index f3ea09a44bbe..700b2de4bdbc 100644 --- a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: readSecret timeout, empty submission, and cancellation contracts +Io\Terminal\SystemTerminal: readSecret timeout, empty submission, and cancellation contracts --SKIPIF-- readSecret(Duration::fromMilliseconds(25)); $elapsed_ms = (hrtime(true) - $start) / 1e6; @@ -83,7 +83,7 @@ $proc2 = proc_open( $pipes2, ); $r0 = fgets($pipes2[2]); -$terminal2 = Terminal::fromStreams($pipes2[0]); +$terminal2 = SystemTerminal::fromStreams($pipes2[0]); $terminal2->enableRawMode(); fwrite($pipes2[0], "START\n"); @@ -122,7 +122,7 @@ $proc3 = proc_open( $pipes3, ); $r0 = fgets($pipes3[2]); -$terminal3 = Terminal::fromStreams($pipes3[0]); +$terminal3 = SystemTerminal::fromStreams($pipes3[0]); $terminal3->enableRawMode(); fwrite($pipes3[0], "START\n"); @@ -161,7 +161,7 @@ $proc4 = proc_open( $pipes4, ); $r0 = fgets($pipes4[2]); -$terminal4 = Terminal::fromStreams($pipes4[0]); +$terminal4 = SystemTerminal::fromStreams($pipes4[0]); $terminal4->enableRawMode(); fwrite($pipes4[0], "START\n"); @@ -191,7 +191,7 @@ $proc5 = proc_open( ], $pipes5, ); -$terminal5 = Terminal::fromStreams($pipes5[0]); +$terminal5 = SystemTerminal::fromStreams($pipes5[0]); try { $terminal5->readSecret(Duration::fromSeconds(1)->negate()); echo "FAIL: expected ValueError on negative duration\n"; @@ -228,7 +228,7 @@ $proc6 = proc_open( $pipes6, ); $r0 = fgets($pipes6[2]); -$terminal6 = Terminal::fromStreams($pipes6[0]); +$terminal6 = SystemTerminal::fromStreams($pipes6[0]); $terminal6->enableRawMode(); fwrite($pipes6[0], "START\n"); @@ -266,7 +266,7 @@ $proc7 = proc_open( $pipes7, ); $r0 = fgets($pipes7[2]); -$terminal7 = Terminal::fromStreams($pipes7[0]); +$terminal7 = SystemTerminal::fromStreams($pipes7[0]); $terminal7->enableRawMode(); fwrite($pipes7[0], "START\n"); @@ -293,6 +293,6 @@ bool(true) bool(true) bool(true) Cancellation caught: Unable to read secret from terminal -Negative timeout caught: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must not be negative +Negative timeout caught: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must not be negative bool(true) Zero timeout escape caught: Unable to read secret from terminal diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt index f0fb31f81cb8..e53b22fc8192 100644 --- a/ext/standard/tests/terminal/terminal_resize.phpt +++ b/ext/standard/tests/terminal/terminal_resize.phpt @@ -33,7 +33,7 @@ proc_close($proc); enableRawMode(); $s1 = $terminal->getSize(); diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt index 76fb73d038b4..6b857c8a5d06 100644 --- a/ext/standard/tests/terminal/terminal_size.phpt +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -1,15 +1,15 @@ --TEST-- -Io\Terminal\Terminal: getSize returns null for non-terminal streams without environment fallback +Io\Terminal\SystemTerminal: getSize returns null for non-terminal streams without environment fallback --ENV-- COLUMNS=100 LINES=30 --FILE-- getSize()); From 279010c72e09fbbc161108ddf2e0ee0de57795f9 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 10:30:34 +0545 Subject: [PATCH 33/38] Fix Windows readLine console mode restore and UTF-8 conversion checks --- ext/standard/io_terminal.c | 27 ++++++-- .../tests/terminal/terminal_read_line.phpt | 47 +++++++++++++ .../terminal/terminal_read_line_windows.phpt | 67 +++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_read_line_windows.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index d22502f37718..9030adf192e5 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -2700,8 +2700,9 @@ static zend_string *php_io_terminal_read_line_windows( } } - if (mode_changed) { - SetConsoleMode(handle, original_mode); + bool restore_failed = false; + if (mode_changed && !SetConsoleMode(handle, original_mode)) { + restore_failed = true; } if (read_failed) { @@ -2710,6 +2711,14 @@ static zend_string *php_io_terminal_read_line_windows( return NULL; } + if (restore_failed) { + efree(wline); + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore console mode", 0); + } + return NULL; + } + if (wlen == 0 || (wlen == 1 && wline[0] == 0x1a)) { efree(wline); return NULL; /* EOF */ @@ -2730,16 +2739,24 @@ static zend_string *php_io_terminal_read_line_windows( } int utf8_len = WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, NULL, 0, NULL, NULL); - if (utf8_len < 0) { + if (utf8_len <= 0) { efree(wline); zend_throw_exception(php_io_terminal_exception_ce, "Failed to convert console line to UTF-8", 0); return NULL; } zend_string *res = zend_string_alloc(utf8_len, 0); - WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, ZSTR_VAL(res), utf8_len, NULL, NULL); - ZSTR_VAL(res)[utf8_len] = '\0'; + int actual_len = WideCharToMultiByte(CP_UTF8, 0, wline, (int) wlen, ZSTR_VAL(res), utf8_len, NULL, NULL); efree(wline); + + if (actual_len <= 0) { + zend_string_release(res); + zend_throw_exception(php_io_terminal_exception_ce, "Failed to convert console line to UTF-8", 0); + return NULL; + } + + ZSTR_VAL(res)[actual_len] = '\0'; + ZSTR_LEN(res) = actual_len; return res; } #else diff --git a/ext/standard/tests/terminal/terminal_read_line.phpt b/ext/standard/tests/terminal/terminal_read_line.phpt index c3278160ce29..07ae421d0f32 100644 --- a/ext/standard/tests/terminal/terminal_read_line.phpt +++ b/ext/standard/tests/terminal/terminal_read_line.phpt @@ -114,6 +114,41 @@ $tMem = SystemTerminal::fromStreams($mem); var_dump($tMem->readLine()); fclose($mem); +// 13. Multiple consecutive empty CRLF lines and trailing CRLF +$fp2 = fopen('php://temp', 'r+'); +fwrite($fp2, "\r\n\r\nline with crlf\r\n\r\n"); +rewind($fp2); +$t2 = SystemTerminal::fromStreams($fp2); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); // EOF + +// 14. Extended Unicode, surrogate pairs, and combining sequences +ftruncate($fp2, 0); +rewind($fp2); +fwrite($fp2, "🐘🦀🚀\r\nHello \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466} Family\r\ne\u{0301}cole\n"); +rewind($fp2); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); +var_dump($t2->readLine()); // EOF + +// 15. Mode restoration & terminal state usability after raw mode attempt +try { + $t2->enableRawMode(); +} catch (Throwable $e) { + echo $e->getMessage(), PHP_EOL; +} +var_dump($t2->restoreMode()); +ftruncate($fp2, 0); +rewind($fp2); +fwrite($fp2, "post restoration line\r\n"); +rewind($fp2); +var_dump($t2->readLine()); +fclose($fp2); + ?> --EXPECT-- bool(true) @@ -146,3 +181,15 @@ string(12) "uffer line 1" string(13) "buffer line 2" NULL string(11) "memory line" +string(0) "" +string(0) "" +string(14) "line with crlf" +string(0) "" +NULL +string(12) "🐘🦀🚀" +string(38) "Hello 👨‍👩‍👧‍👦 Family" +string(7) "école" +NULL +Failed to enable terminal raw mode +bool(false) +string(21) "post restoration line" diff --git a/ext/standard/tests/terminal/terminal_read_line_windows.phpt b/ext/standard/tests/terminal/terminal_read_line_windows.phpt new file mode 100644 index 000000000000..d847fcc73a63 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_windows.phpt @@ -0,0 +1,67 @@ +--TEST-- +Io\Terminal\SystemTerminal: Windows console and stream readLine contracts +--SKIPIF-- + +--FILE-- +readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 2. Windows Unicode line handling with surrogate pairs +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "🐘 PHP \u{1F980} Rust\r\n東京\r\n"); +rewind($fp); + +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); +var_dump($terminal->readLine()); // EOF + +// 3. Mode restoration behavior on Windows: +// Attempting raw mode on non-terminal throws TerminalException, restoreMode returns false, +// and readLine continues to function normally without mode corruption +try { + $terminal->enableRawMode(); +} catch (TerminalException $e) { + echo "enableRawMode exception: ", $e->getMessage(), PHP_EOL; +} +var_dump($terminal->restoreMode()); + +ftruncate($fp, 0); +rewind($fp); +fwrite($fp, "usable after mode check\r\n"); +rewind($fp); +var_dump($terminal->readLine()); + +fclose($fp); + +?> +--EXPECT-- +string(14) "windows line 1" +string(0) "" +string(14) "windows line 2" +NULL +string(18) "🐘 PHP 🦀 Rust" +string(6) "東京" +NULL +enableRawMode exception: Failed to enable terminal raw mode +bool(false) +string(23) "usable after mode check" From daba0cfa6491ec71776ed3cbfe8208e3eb244b90 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 10:55:46 +0545 Subject: [PATCH 34/38] Handle Linux PTY master EIO as EOF during terminal readLine --- ext/standard/io_terminal.c | 124 ++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 16 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 9030adf192e5..03d1f411e14d 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -2804,31 +2804,123 @@ static char *php_io_terminal_read_line_posix( *is_eof = false; *out_len = 0; - if (stream != NULL) { - char *buf = php_stream_get_line(stream, NULL, 0, out_len); - if (buf == NULL) { - if (php_stream_eof(stream)) { - *is_eof = true; + bool is_tty = php_io_terminal_native_stream_is_tty(fd); + if (!is_tty) { + if (stream != NULL) { + char *buf = php_stream_get_line(stream, NULL, 0, out_len); + if (buf == NULL) { + if (php_stream_eof(stream)) { + *is_eof = true; + return NULL; + } + /* Read error */ return NULL; } - /* Read error */ - return NULL; + return buf; } - return buf; + + if (fd >= 0) { + char *buf = php_io_terminal_read_line_from_fd(fd, out_len); + if (buf == NULL) { + if (errno == 0 || errno == EAGAIN) { + *is_eof = true; + } + return NULL; + } + return buf; + } + + return NULL; } - if (fd >= 0) { - char *buf = php_io_terminal_read_line_from_fd(fd, out_len); - if (buf == NULL) { - if (errno == 0 || errno == EAGAIN) { - *is_eof = true; + smart_str line = {0}; + char ch = '\0'; + bool eof_reached = false; + bool error_reached = false; + + /* Drain any already-buffered bytes from php_stream first */ + if (stream != NULL && stream->writepos > stream->readpos) { + while (stream->writepos > stream->readpos) { + size_t read_bytes = php_stream_read(stream, &ch, 1); + if (read_bytes != 1) { + break; + } + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; } - return NULL; } - return buf; } - return NULL; + if (stream != NULL && php_stream_eof(stream)) { + eof_reached = true; + } + + /* If newline was not reached in buffered bytes and stream is not at EOF, read from terminal fd */ + if (ch != '\n' && !eof_reached) { + while (1) { + errno = 0; + ssize_t n = read(fd, &ch, 1); + if (n > 0) { + smart_str_appendc(&line, ch); + if (ch == '\n') { + break; + } + } else if (n == 0) { + eof_reached = true; + break; /* EOF */ + } else { + if (errno == EINTR) { + continue; + } +#if defined(EAGAIN) && defined(EWOULDBLOCK) + if (errno == EAGAIN || errno == EWOULDBLOCK) { + break; + } +#elif defined(EAGAIN) + if (errno == EAGAIN) { + break; + } +#endif +#ifdef EIO + if (errno == EIO) { + /* On Linux, reading from a master PTY returns -1 with errno == EIO + * when the slave side has closed. For a terminal/PTY, this closure + * is end-of-input. */ + eof_reached = true; + break; + } +#endif + error_reached = true; + break; + } + } + } + + if (error_reached) { + smart_str_free(&line); + *is_eof = false; + *out_len = 0; + return NULL; + } + + if (line.s == NULL) { + if (stream != NULL && eof_reached) { + stream->eof = 1; + } + *is_eof = true; + *out_len = 0; + return NULL; + } + + if (eof_reached && stream != NULL) { + stream->eof = 1; + } + + *out_len = ZSTR_LEN(line.s); + char *res = estrndup(ZSTR_VAL(line.s), *out_len); + smart_str_free(&line); + return res; } #endif From 4ba14f4d2c87dd9b52bdc1abd5ad23efb2405ac4 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 11:03:54 +0545 Subject: [PATCH 35/38] Fix EAGAIN/EWOULDBLOCK compile portability in POSIX readLine --- ext/standard/io_terminal.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 03d1f411e14d..b5e8a69c1041 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -2873,11 +2873,11 @@ static char *php_io_terminal_read_line_posix( if (errno == EINTR) { continue; } -#if defined(EAGAIN) && defined(EWOULDBLOCK) +#if defined(EWOULDBLOCK) && (EWOULDBLOCK != EAGAIN) if (errno == EAGAIN || errno == EWOULDBLOCK) { break; } -#elif defined(EAGAIN) +#else if (errno == EAGAIN) { break; } From 58262cc726d87c182c496b02fd0686a57c050526 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 12:15:19 +0545 Subject: [PATCH 36/38] Fix stale token cleanup on readLine and increase UTF-8 retain timeout --- ext/standard/io_terminal.c | 30 ++++++++++++------- .../terminal_read_key_utf8_retain.phpt | 6 ++-- .../terminal/terminal_read_line_lease.phpt | 6 ++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index b5e8a69c1041..6aec4fcd35de 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -287,6 +287,20 @@ static void php_io_terminal_restore_resize_handler(void) #define PHP_IO_TERMINAL_OBJ_FROM_ZV(_zv) \ PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(Z_OBJ_P(_zv)) +static inline php_io_terminal_mode_token_object *php_io_terminal_get_active_mode_token(php_io_terminal_object *intern) +{ + if (intern->active_mode_token != NULL) { + php_io_terminal_mode_token_object *token = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); + if (!token->valid || token->shared == NULL) { + OBJ_RELEASE(intern->active_mode_token); + intern->active_mode_token = NULL; + return NULL; + } + return token; + } + return NULL; +} + static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream); static bool php_io_terminal_native_stream_is_valid(php_io_terminal_native_stream stream) @@ -2380,7 +2394,8 @@ PHP_METHOD(Io_Terminal_SystemTerminal, restoreMode) } bool matches = false; - if (intern->active_mode_token != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { + php_io_terminal_mode_token_object *active = php_io_terminal_get_active_mode_token(intern); + if (active != NULL && intern->active_mode_token == Z_OBJ_P(mode_token)) { matches = true; } else { php_io_terminal_stream_target stream; @@ -2414,15 +2429,8 @@ PHP_METHOD(Io_Terminal_SystemTerminal, restoreMode) RETURN_TRUE; } - if (intern->active_mode_token != NULL) { - php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZOBJ(intern->active_mode_token); - - if (!mode->valid || mode->shared == NULL) { - OBJ_RELEASE(intern->active_mode_token); - intern->active_mode_token = NULL; - RETURN_FALSE; - } - + php_io_terminal_mode_token_object *mode = php_io_terminal_get_active_mode_token(intern); + if (mode != NULL) { const char *err = NULL; if (!php_io_terminal_release_token_lease(mode, &err)) { zend_throw_exception(php_io_terminal_exception_ce, err != NULL ? err : "Failed to restore terminal mode", 0); @@ -2939,7 +2947,7 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readLine) intern->has_identity = php_io_terminal_get_identity(stream.native_stream, &intern->identity); } - if (intern->active_mode_token != NULL || (intern->has_identity && php_io_terminal_find_shared_mode(&intern->identity) != NULL)) { + if (php_io_terminal_get_active_mode_token(intern) != NULL || (intern->has_identity && php_io_terminal_find_shared_mode(&intern->identity) != NULL)) { zend_throw_exception(php_io_terminal_exception_ce, "Cannot read a line while raw mode is active for this terminal", 0); RETURN_THROWS(); } diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt index e81bb87879b2..3382f116ab87 100644 --- a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt @@ -143,7 +143,7 @@ proc_close($proc2); // 3. 4-byte UTF-8 sequence where more than one byte (2 bytes: \xf0\x9f) is already pending, // and the remaining bytes arrive later than sequenceTimeout (20ms) but within the next call's -// overall timeout (150ms). +// overall timeout (1000ms). $code3 = ' fwrite(STDERR, "STARTED\n"); fflush(STDERR); @@ -186,8 +186,8 @@ $k5 = $terminal3->readKey(Duration::fromMilliseconds(30), Duration::fromMillisec fwrite($pipes3[0], "GO\n"); // Call 2 resumes sequence with 2 bytes already pending. The first continuation read of this invocation -// uses overall timeout (150ms) rather than sequenceTimeout (20ms), successfully waiting for the 40ms delay. -$k6 = $terminal3->readKey(Duration::fromMilliseconds(150), Duration::fromMilliseconds(20)); +// uses overall timeout (1000ms) rather than sequenceTimeout (20ms), successfully waiting for the 40ms delay. +$k6 = $terminal3->readKey(Duration::fromMilliseconds(1000), Duration::fromMilliseconds(20)); echo "k5 is null: ", var_export($k5 === null, true), PHP_EOL; echo "k6 is emoji: ", var_export($k6 === "😀", true), PHP_EOL; diff --git a/ext/standard/tests/terminal/terminal_read_line_lease.phpt b/ext/standard/tests/terminal/terminal_read_line_lease.phpt index 38369019aea2..23ecb2d35159 100644 --- a/ext/standard/tests/terminal/terminal_read_line_lease.phpt +++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt @@ -98,11 +98,11 @@ try { echo "Cross-instance: ", $e->getMessage(), PHP_EOL; } -// 3. Terminal state remains usable after failure and restoration -$restored = $t1->restoreMode($token); +// 3. Cross-instance restoreMode($token) succeeds and restores mode across wrappers +$restored = $t2->restoreMode($token); var_dump($restored); -// 4. readLine works normally after raw mode is restored +// 4. readLine on original wrapper works normally after cross-wrapper restoration fwrite($pipes[0], "GO\n"); fgets($pipes[2]); // Wait for READY1 var_dump($t1->readLine()); From 692520776aa7309d031ca9847a9569cac83c86bd Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 12:27:00 +0545 Subject: [PATCH 37/38] Clean restoreMode comment type names and assert no-arg stale restoreMode --- ext/standard/io_terminal.c | 12 ++++++------ .../tests/terminal/terminal_read_line_lease.phpt | 10 ++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 6aec4fcd35de..370e38853a42 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -2356,17 +2356,17 @@ PHP_METHOD(Io_Terminal_SystemTerminal, enableRawMode) } /* - * Invariant: A native Terminal implementation may only restore a native - * ModeToken associated with the same underlying terminal/shared mode record. + * Invariant: A native SystemTerminal implementation may only restore a native + * SystemModeToken associated with the same underlying terminal/shared mode record. * * The native implementation intentionally allows a token to be restored by - * another Terminal object that refers to the same underlying terminal identity + * another SystemTerminal object that refers to the same underlying terminal identity * or shared mode record. * - * TerminalInterface::restoreMode(?ModeTokenInterface $mode = null) accepts the + * Terminal::restoreMode(?ModeToken $mode = null) accepts the * interface to allow polymorphic userland terminal fakes (e.g. for unit testing). - * However, the native Terminal implementation requires native C state attached to - * an internal ModeToken instance associated with the same underlying terminal or + * However, the native SystemTerminal implementation requires native C state attached to + * an internal SystemModeToken instance associated with the same underlying terminal or * shared mode record; foreign userland token implementations must still be rejected * with ValueError. */ diff --git a/ext/standard/tests/terminal/terminal_read_line_lease.phpt b/ext/standard/tests/terminal/terminal_read_line_lease.phpt index 23ecb2d35159..4cceffa17481 100644 --- a/ext/standard/tests/terminal/terminal_read_line_lease.phpt +++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt @@ -112,7 +112,12 @@ fwrite($pipes[0], "GO\n"); fgets($pipes[2]); // Wait for READY2 var_dump($t2->readLine()); -// 6. Fragmented UTF-8 prepending: pending UTF-8 bytes from readKey are prepended to readLine +// 6. No-argument restoreMode() on a wrapper with a stale active token returns false +$tokenStale = $t1->enableRawMode(); +$t2->restoreMode($tokenStale); +var_dump($t1->restoreMode() === false); + +// 7. Fragmented UTF-8 prepending: pending UTF-8 bytes from readKey are prepended to readLine $token2 = $t1->enableRawMode(); fwrite($pipes[0], "GO\n"); fgets($pipes[2]); // Wait for READY3 @@ -128,7 +133,7 @@ fgets($pipes[2]); // Wait for READY4 $line = $t1->readLine(); var_dump($line); -// 7. Immediate EOF on PTY returns null +// 8. Immediate EOF on PTY returns null fwrite($pipes[0], "DONE\n"); usleep(50000); // Allow child process to finish var_dump($t1->readLine()); @@ -147,5 +152,6 @@ bool(true) string(14) "canonical line" string(11) "second line" bool(true) +bool(true) string(7) "éclair" NULL From 1fc4061900e4047c2d67434878fccfc49e360cdc Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Wed, 30 Sep 2026 12:49:34 +0545 Subject: [PATCH 38/38] Wait for readability on EAGAIN in POSIX readLine to preserve submitted-line contract --- ext/standard/io_terminal.c | 100 ++++++++--- .../terminal_read_line_nonblocking.phpt | 163 ++++++++++++++++++ 2 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 370e38853a42..34a8793c05ef 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -2768,12 +2768,44 @@ static zend_string *php_io_terminal_read_line_windows( return res; } #else -static char *php_io_terminal_read_line_from_fd(int fd, size_t *out_len) +static bool php_io_terminal_wait_fd_readable(int fd, php_poll_ctx **poll_ctx) { + if (*poll_ctx == NULL) { + *poll_ctx = php_io_terminal_create_poll_context(fd); + if (*poll_ctx == NULL) { + return false; + } + } + + php_poll_event event; + int poll_result; + do { + if (EG(exception)) { + return false; + } + poll_result = php_poll_wait(*poll_ctx, &event, 1, NULL); + } while (poll_result < 0 && php_poll_get_error(*poll_ctx) == PHP_POLL_ERR_INTERRUPTED); + + if (EG(exception)) { + return false; + } + + return poll_result > 0; +} + +static char *php_io_terminal_read_line_from_fd(int fd, size_t *out_len, bool *is_eof) +{ + *is_eof = false; + *out_len = 0; + smart_str line = {0}; - char ch; - errno = 0; + char ch = '\0'; + bool eof_reached = false; + bool error_reached = false; + php_poll_ctx *poll_ctx = NULL; + while (1) { + errno = 0; ssize_t n = read(fd, &ch, 1); if (n > 0) { smart_str_appendc(&line, ch); @@ -2781,17 +2813,43 @@ static char *php_io_terminal_read_line_from_fd(int fd, size_t *out_len) break; } } else if (n == 0) { + eof_reached = true; break; /* EOF */ } else { if (errno == EINTR) { continue; } - smart_str_free(&line); - return NULL; +#if defined(EWOULDBLOCK) && (EWOULDBLOCK != EAGAIN) + if (errno == EAGAIN || errno == EWOULDBLOCK) { +#else + if (errno == EAGAIN) { +#endif + if (!php_io_terminal_wait_fd_readable(fd, &poll_ctx)) { + error_reached = true; + break; + } + continue; + } + error_reached = true; + break; } } + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } + + if (error_reached) { + smart_str_free(&line); + *is_eof = false; + *out_len = 0; + return NULL; + } + if (line.s == NULL) { + if (eof_reached) { + *is_eof = true; + } *out_len = 0; return NULL; } @@ -2828,14 +2886,7 @@ static char *php_io_terminal_read_line_posix( } if (fd >= 0) { - char *buf = php_io_terminal_read_line_from_fd(fd, out_len); - if (buf == NULL) { - if (errno == 0 || errno == EAGAIN) { - *is_eof = true; - } - return NULL; - } - return buf; + return php_io_terminal_read_line_from_fd(fd, out_len, is_eof); } return NULL; @@ -2845,6 +2896,7 @@ static char *php_io_terminal_read_line_posix( char ch = '\0'; bool eof_reached = false; bool error_reached = false; + php_poll_ctx *poll_ctx = NULL; /* Drain any already-buffered bytes from php_stream first */ if (stream != NULL && stream->writepos > stream->readpos) { @@ -2883,13 +2935,15 @@ static char *php_io_terminal_read_line_posix( } #if defined(EWOULDBLOCK) && (EWOULDBLOCK != EAGAIN) if (errno == EAGAIN || errno == EWOULDBLOCK) { - break; - } #else if (errno == EAGAIN) { - break; - } #endif + if (!php_io_terminal_wait_fd_readable(fd, &poll_ctx)) { + error_reached = true; + break; + } + continue; + } #ifdef EIO if (errno == EIO) { /* On Linux, reading from a master PTY returns -1 with errno == EIO @@ -2905,6 +2959,10 @@ static char *php_io_terminal_read_line_posix( } } + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } + if (error_reached) { smart_str_free(&line); *is_eof = false; @@ -2913,10 +2971,12 @@ static char *php_io_terminal_read_line_posix( } if (line.s == NULL) { - if (stream != NULL && eof_reached) { - stream->eof = 1; + if (eof_reached) { + if (stream != NULL) { + stream->eof = 1; + } + *is_eof = true; } - *is_eof = true; *out_len = 0; return NULL; } diff --git a/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt new file mode 100644 index 000000000000..decc1741b975 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt @@ -0,0 +1,163 @@ +--TEST-- +Io\Terminal\SystemTerminal: readLine nonblocking TTY EAGAIN waiting, partial lines, and EOF contracts +--SKIPIF-- + ['pty'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); +} catch (Throwable) { + die("skip PTY is not available"); +} +if (!is_resource($proc)) { + die("skip PTY is not available"); +} +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--FILE-- + ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes1, +); + +fgets($pipes1[2]); // Wait for STARTED +stream_set_blocking($pipes1[1], false); +$terminal1 = SystemTerminal::fromStreams($pipes1[1]); + +fwrite($pipes1[0], "GO\n"); +// Even though $pipes1[1] is nonblocking and child sleeps 40ms before writing, +// readLine() waits for readiness and returns the line instead of falsely returning null/EOF. +var_dump($terminal1->readLine()); + +fclose($pipes1[0]); +fclose($pipes1[1]); +fclose($pipes1[2]); +proc_close($proc1); + +// 2. Nonblocking TTY, partial bytes without newline: must not return them as a completed line +$proc2 = proc_open( + [PHP_BINARY, '-r', ' + fwrite(STDERR, "STARTED\n"); + fflush(STDERR); + fgets(STDIN); + fwrite(STDOUT, "partial_"); + fflush(STDOUT); + usleep(40000); + fwrite(STDOUT, "completed\n"); + fflush(STDOUT); + '], + [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes2, +); + +fgets($pipes2[2]); // Wait for STARTED +stream_set_blocking($pipes2[1], false); +$terminal2 = SystemTerminal::fromStreams($pipes2[1]); + +fwrite($pipes2[0], "GO\n"); +// Even though the child pauses after writing "partial_", readLine() must not return +// partial bytes as a completed line; it waits for the newline and returns the whole line. +var_dump($terminal2->readLine()); + +fclose($pipes2[0]); +fclose($pipes2[1]); +fclose($pipes2[2]); +proc_close($proc2); + +// 3. Actual EOF before data on nonblocking TTY returns null +$proc3 = proc_open( + [PHP_BINARY, '-r', 'exit(0);'], + [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes3, +); + +fclose($pipes3[0]); +fclose($pipes3[2]); +usleep(30000); // Allow child to exit and slave to close +stream_set_blocking($pipes3[1], false); +$terminal3 = SystemTerminal::fromStreams($pipes3[1]); + +var_dump($terminal3->readLine()); + +fclose($pipes3[1]); +proc_close($proc3); + +// 4. Partial bytes + actual EOF on nonblocking TTY returns final unterminated line, then null +$proc4 = proc_open( + [PHP_BINARY, '-r', ' + fwrite(STDERR, "STARTED\n"); + fflush(STDERR); + fgets(STDIN); + fwrite(STDOUT, "final unterminated"); + fflush(STDOUT); + exit(0); + '], + [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pipe', 'w'], + ], + $pipes4, +); + +fgets($pipes4[2]); // Wait for STARTED +stream_set_blocking($pipes4[1], false); +$terminal4 = SystemTerminal::fromStreams($pipes4[1]); + +fwrite($pipes4[0], "GO\n"); +usleep(30000); // Allow child to write and exit +var_dump($terminal4->readLine()); +var_dump($terminal4->readLine()); // Immediate EOF after unterminated line + +fclose($pipes4[0]); +fclose($pipes4[1]); +fclose($pipes4[2]); +proc_close($proc4); + +?> +--EXPECT-- +string(12) "delayed line" +string(17) "partial_completed" +NULL +string(18) "final unterminated" +NULL