From b97762279f81fd438cbe3f6b9775bbcb35344a60 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sat, 26 Sep 2026 21:09:49 +0545 Subject: [PATCH 01/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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 From d0b014878a698d90def37b20b32de7e0b278b7b4 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 02:10:53 +0545 Subject: [PATCH 39/52] Io\Terminal: Align API to lease model and harden cross-platform stream handling - Refactor public API to final Terminal and ModeToken classes under Nicolas's shared lease model - Implement explicit mode restoration via restoreMode(ModeToken $mode): void with transactional rollback - Implement bounded readiness and overall sequence deadlines for ordinary streams on POSIX and Windows - Unify byte-stream readSecret cancellation (Escape, Ctrl+C, Ctrl+D) and SS3/CSI controls across platforms - Harden CRLF lookahead across secret and line reading, preserving data CR before CRLF and at EOF - Isolate test seam hooks to debug/test builds (ZEND_DEBUG || PHP_TERMINAL_TEST_BUILD) - Add regression PHPT test suite covering fork isolation, failure injection, output flags, and streams --- ...flectionExtension_getClassNames_basic.phpt | 2 - ext/standard/io_terminal.c | 1834 ++++++++++++----- ext/standard/io_terminal.stub.php | 38 +- ext/standard/io_terminal_arginfo.h | 129 +- ext/standard/io_terminal_decl.h | 40 +- .../terminal_alternating_wrappers.phpt | 47 + .../terminal/terminal_controlling_tty.phpt | 27 +- .../tests/terminal/terminal_create.phpt | 42 +- .../tests/terminal/terminal_identity.phpt | 54 +- .../terminal/terminal_interface_mock.phpt | 104 +- .../terminal/terminal_key_line_handoff.phpt | 47 + .../terminal/terminal_key_secret_handoff.phpt | 86 + .../terminal_overlapping_raw_mode.phpt | 93 +- .../tests/terminal/terminal_raw_mode.phpt | 69 +- .../terminal_raw_mode_failure_injection.phpt | 126 ++ .../terminal/terminal_raw_mode_fork.phpt | 114 + .../terminal/terminal_raw_mode_non_tty.phpt | 36 +- .../terminal_raw_mode_output_flags.phpt | 115 ++ .../tests/terminal/terminal_read_key.phpt | 33 +- .../terminal/terminal_read_key_contracts.phpt | 40 +- .../terminal_read_key_partial_escape.phpt | 10 +- .../terminal/terminal_read_key_sequences.phpt | 10 +- .../terminal/terminal_read_key_timeout.phpt | 6 +- .../terminal_read_key_utf8_retain.phpt | 18 +- .../terminal_read_key_utf8_timing.phpt | 28 +- .../tests/terminal/terminal_read_line.phpt | 30 +- .../terminal/terminal_read_line_lease.phpt | 25 +- .../terminal_read_line_nonblocking.phpt | 16 +- .../terminal/terminal_read_line_windows.phpt | 25 +- .../tests/terminal/terminal_read_secret.phpt | 18 +- .../terminal_read_secret_control.phpt | 173 +- .../terminal_read_secret_timeout.phpt | 30 +- .../tests/terminal/terminal_resize.phpt | 4 +- .../terminal_secret_crlf_handoff.phpt | 189 ++ .../tests/terminal/terminal_size.phpt | 6 +- .../terminal_stream_secret_controls.phpt | 119 ++ .../terminal_stream_sequence_timeout.phpt | 129 ++ .../terminal/terminal_stream_timeout.phpt | 121 ++ 38 files changed, 3015 insertions(+), 1018 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_alternating_wrappers.phpt create mode 100644 ext/standard/tests/terminal/terminal_key_line_handoff.phpt create mode 100644 ext/standard/tests/terminal/terminal_key_secret_handoff.phpt create mode 100644 ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt create mode 100644 ext/standard/tests/terminal/terminal_raw_mode_fork.phpt create mode 100644 ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt create mode 100644 ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt create mode 100644 ext/standard/tests/terminal/terminal_stream_secret_controls.phpt create mode 100644 ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt create mode 100644 ext/standard/tests/terminal/terminal_stream_timeout.phpt diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index 0259162e7def..c0b95563215e 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -32,8 +32,6 @@ Io\Poll\PollException Io\Poll\Watcher Io\Terminal\Key Io\Terminal\ModeToken -Io\Terminal\SystemModeToken -Io\Terminal\SystemTerminal Io\Terminal\Terminal Io\Terminal\TerminalException Io\Terminal\TerminalSize diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 34a8793c05ef..ead42659c1c9 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -69,6 +69,7 @@ typedef HANDLE php_io_terminal_native_stream; #else typedef int php_io_terminal_native_stream; # define PHP_IO_TERMINAL_INVALID_NATIVE_STREAM (-1) +#endif static inline zend_hrtime_t php_io_terminal_timespec_to_ns(const struct timespec *ts) { @@ -99,12 +100,14 @@ typedef struct php_io_terminal_utf8_pending { size_t length; size_t expected; } php_io_terminal_utf8_pending; -#endif #ifdef PHP_WIN32 typedef struct php_io_terminal_identity { php_io_terminal_native_stream stream; bool is_console; + bool is_non_tty_stream; + zend_ulong stream_res_id; + php_stream *php_stream_ptr; } php_io_terminal_identity; #else typedef struct php_io_terminal_identity { @@ -113,6 +116,9 @@ typedef struct php_io_terminal_identity { bool has_dev; pid_t sid; bool has_sid; + bool is_non_tty_stream; + zend_ulong stream_res_id; + php_stream *php_stream_ptr; } php_io_terminal_identity; #endif @@ -125,6 +131,10 @@ typedef struct php_io_terminal_shared_mode { struct termios saved_mode; #endif uint32_t lease_count; + uint32_t refcount; + pid_t owner_pid; + bool is_non_tty; + bool is_restored; struct php_io_terminal_shared_mode *prev; struct php_io_terminal_shared_mode *next; } php_io_terminal_shared_mode; @@ -132,6 +142,7 @@ typedef struct php_io_terminal_shared_mode { typedef struct php_io_terminal_mode_token_object { php_io_terminal_shared_mode *shared; bool valid; + pid_t owner_pid; zend_object std; } php_io_terminal_mode_token_object; @@ -140,7 +151,6 @@ typedef struct php_io_terminal_object { 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; bool has_last_size; @@ -148,9 +158,9 @@ typedef struct php_io_terminal_object { INPUT_RECORD pending_key; WCHAR pending_key_high_surrogate; WCHAR pending_high_surrogate; -#else - php_io_terminal_utf8_pending pending_utf8; #endif + php_io_terminal_utf8_pending pending_utf8; + bool last_secret_ended_in_cr; zend_object std; } php_io_terminal_object; @@ -166,12 +176,16 @@ 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_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; +#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 + #if !defined(PHP_WIN32) # if defined(HAVE_PTSNAME_R) || defined(_GNU_SOURCE) || defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__) # define PHP_IO_TERMINAL_HAVE_PTSNAME_R 1 @@ -185,12 +199,6 @@ static MUTEX_T php_io_terminal_ptsname_mutex = NULL; # define PHP_IO_TERMINAL_PTSNAME_UNLOCK() # 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; static unsigned int php_io_terminal_resize_readers = 0; @@ -287,22 +295,6 @@ 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) { #ifdef PHP_WIN32 @@ -325,9 +317,46 @@ static bool php_io_terminal_native_stream_is_tty(php_io_terminal_native_stream s #endif } +static void php_io_terminal_shared_mode_addref(php_io_terminal_shared_mode *shared) +{ + shared->refcount++; +} + +static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream); + +static void php_io_terminal_shared_mode_delref(php_io_terminal_shared_mode *shared) +{ + if (--shared->refcount == 0) { + 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; + } + efree(shared); + } +} + +static void php_io_terminal_remove_active_shared_mode(php_io_terminal_shared_mode *shared) +{ + if (shared->prev != NULL) { + shared->prev->next = shared->next; + } else if (php_io_terminal_active_shared_modes == shared) { + php_io_terminal_active_shared_modes = shared->next; + } + + if (shared->next != NULL) { + shared->next->prev = shared->prev; + } + + shared->prev = NULL; + shared->next = NULL; +} + +static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream); + #ifdef PHP_WIN32 static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity) { + memset(identity, 0, sizeof(*identity)); identity->stream = handle; identity->is_console = false; if (!php_io_terminal_native_stream_is_valid(handle)) { @@ -343,7 +372,13 @@ static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, p static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) { - return first->is_console && second->is_console; + if (first->is_non_tty_stream && second->is_non_tty_stream) { + if (first->stream_res_id != 0 && second->stream_res_id != 0) { + return first->stream_res_id == second->stream_res_id; + } + return first->php_stream_ptr == second->php_stream_ptr; + } + return first->is_console && second->is_console && !first->is_non_tty_stream && !second->is_non_tty_stream; } #else #ifndef PATH_MAX @@ -432,6 +467,17 @@ 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->is_non_tty_stream && second->is_non_tty_stream) { + if (first->stream_res_id != 0 && second->stream_res_id != 0) { + return first->stream_res_id == second->stream_res_id; + } + return first->php_stream_ptr == second->php_stream_ptr; + } + + if (first->is_non_tty_stream || second->is_non_tty_stream) { + return false; + } + if (first->has_dev && second->has_dev) { return first->dev == second->dev; } @@ -514,8 +560,9 @@ static void php_io_terminal_close_native_stream(php_io_terminal_native_stream st 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; + pid_t current_pid = getpid(); while (curr != NULL) { - if (php_io_terminal_identities_match(&curr->identity, identity)) { + if (curr->owner_pid == current_pid && php_io_terminal_identities_match(&curr->identity, identity)) { return curr; } curr = curr->next; @@ -523,12 +570,50 @@ static php_io_terminal_shared_mode *php_io_terminal_find_shared_mode(const php_i return NULL; } +/* Fault-injection hooks are compiled only into debug builds (ZEND_DEBUG is + * always defined by php-src, as 0 or 1, so its value must be tested) or into + * private builds that explicitly define PHP_TERMINAL_TEST_BUILD. */ +#if ZEND_DEBUG || defined(PHP_TERMINAL_TEST_BUILD) +# define PHP_IO_TERMINAL_TEST_SEAM_ENABLED 1 +#else +# define PHP_IO_TERMINAL_TEST_SEAM_ENABLED 0 +#endif + +static inline const char *php_io_terminal_get_test_seam(void) +{ +#if PHP_IO_TERMINAL_TEST_SEAM_ENABLED + return getenv("PHP_TERMINAL_TEST_SEAM"); +#else + return NULL; +#endif +} + +static inline void php_io_terminal_clear_test_seam(void) +{ +#if PHP_IO_TERMINAL_TEST_SEAM_ENABLED +#ifndef PHP_WIN32 + unsetenv("PHP_TERMINAL_TEST_SEAM"); +#else + SetEnvironmentVariableA("PHP_TERMINAL_TEST_SEAM", NULL); +#endif +#endif +} + static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mode *shared) { + if (shared->is_non_tty) { + return true; + } if (!php_io_terminal_native_stream_is_valid(shared->restore_stream)) { return false; } + const char *seam = php_io_terminal_get_test_seam(); + if (seam != NULL && strcmp(seam, "fail_restore_once") == 0) { + php_io_terminal_clear_test_seam(); + return false; + } + #ifdef PHP_WIN32 if (!SetConsoleMode(shared->restore_stream, shared->saved_mode)) { return false; @@ -551,29 +636,6 @@ static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mod return true; } -static void php_io_terminal_destroy_shared_mode(php_io_terminal_shared_mode *shared) -{ - if (shared->prev != NULL) { - shared->prev->next = shared->next; - } else { - php_io_terminal_active_shared_modes = shared->next; - } - - if (shared->next != NULL) { - shared->next->prev = shared->prev; - } - - shared->prev = NULL; - shared->next = NULL; - - 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; - } - - efree(shared); -} - static bool php_io_terminal_release_token_lease(php_io_terminal_mode_token_object *token_obj, const char **error_msg) { php_io_terminal_shared_mode *shared = token_obj->shared; @@ -585,13 +647,37 @@ static bool php_io_terminal_release_token_lease(php_io_terminal_mode_token_objec return false; } + if (token_obj->owner_pid != getpid()) { + if (error_msg != NULL) { + *error_msg = "Cannot restore terminal mode from another process"; + } + return false; + } + + if (shared->is_non_tty) { + if (shared->lease_count > 1) { + shared->lease_count--; + } else { + shared->lease_count = 0; + shared->is_restored = true; + php_io_terminal_remove_active_shared_mode(shared); + php_io_terminal_shared_mode_delref(shared); + } + token_obj->valid = false; + token_obj->shared = NULL; + php_io_terminal_shared_mode_delref(shared); + return true; + } + if (shared->lease_count > 1) { shared->lease_count--; token_obj->valid = false; token_obj->shared = NULL; + php_io_terminal_shared_mode_delref(shared); return true; } + /* lease_count == 1: attempt hardware/OS restore */ if (!php_io_terminal_restore_shared_mode(shared)) { if (error_msg != NULL) { *error_msg = "Failed to restore terminal mode"; @@ -599,10 +685,14 @@ static bool php_io_terminal_release_token_lease(php_io_terminal_mode_token_objec return false; } + shared->lease_count = 0; + shared->is_restored = true; + php_io_terminal_remove_active_shared_mode(shared); + php_io_terminal_shared_mode_delref(shared); + token_obj->valid = false; token_obj->shared = NULL; - - php_io_terminal_destroy_shared_mode(shared); + php_io_terminal_shared_mode_delref(shared); return true; } @@ -611,8 +701,15 @@ 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) { - if (!php_io_terminal_release_token_lease(intern, NULL)) { + if (intern->owner_pid == getpid()) { + if (!php_io_terminal_release_token_lease(intern, NULL)) { + intern->valid = false; + php_io_terminal_shared_mode_delref(intern->shared); + intern->shared = NULL; + } + } else { intern->valid = false; + php_io_terminal_shared_mode_delref(intern->shared); intern->shared = NULL; } } @@ -626,6 +723,7 @@ static zend_object *php_io_terminal_mode_token_create_object(zend_class_entry *c intern->shared = NULL; intern->valid = false; + intern->owner_pid = getpid(); zend_object_std_init(&intern->std, ce); object_properties_init(&intern->std, ce); @@ -638,21 +736,18 @@ 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_system_mode_token_ce); + object_init_ex(return_value, php_io_terminal_mode_token_ce); intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value); + php_io_terminal_shared_mode_addref(shared); intern->shared = shared; intern->valid = true; + intern->owner_pid = getpid(); } 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) { - OBJ_RELEASE(intern->active_mode_token); - intern->active_mode_token = NULL; - } - zval_ptr_dtor(&intern->input_stream_val); zval_ptr_dtor(&intern->output_stream_val); @@ -667,7 +762,6 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) 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; @@ -676,9 +770,9 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) 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)); #endif + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); + intern->last_secret_ended_in_cr = false; zend_object_std_init(&intern->std, ce); object_properties_init(&intern->std, ce); @@ -687,22 +781,30 @@ 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 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) { + if (descriptor == INVALID_SOCKET) { + return INVALID_HANDLE_VALUE; + } + + int opt = 0; + int optlen = sizeof(opt); + if (getsockopt((SOCKET) descriptor, SOL_SOCKET, SO_TYPE, (char *) &opt, &optlen) == 0) { + return (HANDLE)(intptr_t) descriptor; + } + + if ((uintptr_t) descriptor > INT_MAX) { return INVALID_HANDLE_VALUE; } - os_handle = _get_osfhandle((int) descriptor); + intptr_t 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; @@ -802,20 +904,49 @@ static DWORD php_io_terminal_make_raw_mode(DWORD mode) static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( php_io_terminal_native_stream handle, - const php_io_terminal_identity *identity + php_stream *stream, + const php_io_terminal_identity *identity, + bool is_console ) { 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); + const char *seam = php_io_terminal_get_test_seam(); + if (seam != NULL && strcmp(seam, "fail_reapply") == 0) { + return NULL; + } + if (is_console) { + 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); + if (!SetConsoleMode(handle, raw)) { + return NULL; + } + } } shared->lease_count++; return shared; } + if (!is_console) { + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; + shared->saved_mode = 0; + shared->lease_count = 1; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = true; + shared->is_restored = false; + 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; + } + DWORD mode; if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) { return NULL; @@ -827,13 +958,35 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( } DWORD raw_mode = php_io_terminal_make_raw_mode(mode); - if (!SetConsoleMode(handle, raw_mode)) { + const char *seam = php_io_terminal_get_test_seam(); + if ((seam != NULL && strcmp(seam, "fail_first_acquire") == 0) || !SetConsoleMode(handle, raw_mode)) { php_io_terminal_close_native_stream(restore_stream); return NULL; } DWORD actual_mode; - if (GetConsoleMode(handle, &actual_mode) && (actual_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0) { + if ((seam != NULL && strcmp(seam, "fail_postcondition") == 0) || + (GetConsoleMode(handle, &actual_mode) && (actual_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0)) { + /* Roll back to original saved mode before closing restore handle */ + if (!SetConsoleMode(handle, mode)) { + /* Rollback failed: preserve restoration state so cleanup or retry can restore it */ + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 0; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; + 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 NULL; + } php_io_terminal_close_native_stream(restore_stream); return NULL; } @@ -843,6 +996,10 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( shared->restore_stream = restore_stream; shared->saved_mode = mode; shared->lease_count = 1; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; shared->prev = NULL; shared->next = php_io_terminal_active_shared_modes; if (php_io_terminal_active_shared_modes != NULL) { @@ -892,6 +1049,8 @@ static zend_string *php_io_terminal_key_from_virtual_key(WORD vk) return ZSTR_INIT_LITERAL("end", false); case VK_DELETE: return ZSTR_INIT_LITERAL("delete", false); + case VK_INSERT: + return ZSTR_INIT_LITERAL("insert", false); case VK_PRIOR: return ZSTR_INIT_LITERAL("pageup", false); case VK_NEXT: @@ -963,6 +1122,11 @@ static zend_string *php_io_terminal_key_from_wchar(WCHAR ch, WCHAR *high_surroga static zend_string *php_io_terminal_key_from_input_record(const KEY_EVENT_RECORD *key, WCHAR *high_surrogate) { + if (key->wVirtualKeyCode == VK_TAB && (key->dwControlKeyState & SHIFT_PRESSED) != 0) { + *high_surrogate = 0; + return ZSTR_INIT_LITERAL("shifttab", false); + } + zend_string *named_key = php_io_terminal_key_from_virtual_key(key->wVirtualKeyCode); if (named_key != NULL) { *high_surrogate = 0; @@ -1005,9 +1169,8 @@ static bool php_io_terminal_read_console_record( return true; } -static zend_string *php_io_terminal_read_stream_key( +static zend_string *php_io_terminal_read_console_key( php_io_terminal_native_stream input, - php_stream *stream, DWORD wait_ms, WCHAR *pending_high_surrogate, INPUT_RECORD *pending_key, @@ -1021,12 +1184,6 @@ static zend_string *php_io_terminal_read_stream_key( zend_string *result = NULL; bool mode_changed = false; - 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; @@ -1097,11 +1254,11 @@ static zend_string *php_io_terminal_read_stream_key( return result; } -static zend_string *php_io_terminal_read_stream_secret( +static zend_string *php_io_terminal_read_console_secret( php_io_terminal_native_stream input, - php_stream *stream, INPUT_RECORD *pending_key, WCHAR *pending_key_high_surrogate, + WCHAR *pending_high_surrogate, DWORD wait_ms, bool has_timeout, bool *timed_out @@ -1111,9 +1268,7 @@ static zend_string *php_io_terminal_read_stream_secret( HANDLE handle = input; DWORD mode; - if (handle == INVALID_HANDLE_VALUE || handle == NULL - || (stream != NULL && stream->writepos > stream->readpos) - || !GetConsoleMode(handle, &mode)) { + if (handle == INVALID_HANDLE_VALUE || handle == NULL || !GetConsoleMode(handle, &mode)) { return NULL; } @@ -1123,7 +1278,7 @@ static zend_string *php_io_terminal_read_stream_secret( } bool mode_changed = raw_mode != mode; - WCHAR high_surrogate = 0; + WCHAR high_surrogate = *pending_high_surrogate; smart_str secret = {0}; bool success = false; bool failed = false; @@ -1213,6 +1368,8 @@ static zend_string *php_io_terminal_read_stream_secret( } } + *pending_high_surrogate = high_surrogate; + if (mode_changed && !SetConsoleMode(handle, mode)) { failed = true; *timed_out = false; @@ -1226,12 +1383,12 @@ static zend_string *php_io_terminal_read_stream_secret( return NULL; } #else -/* POSIX implementation */ +/* POSIX raw mode & size */ 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; + /* Do NOT disable OPOST; preserve output processing to avoid staircase newlines */ mode->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); mode->c_cflag &= ~(CSIZE | PARENB); mode->c_cflag |= CS8; @@ -1241,21 +1398,50 @@ static void php_io_terminal_make_raw_mode(struct termios *mode) static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( php_io_terminal_native_stream fd, - const php_io_terminal_identity *identity + php_stream *stream, + const php_io_terminal_identity *identity, + bool is_tty ) { 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); + const char *seam = php_io_terminal_get_test_seam(); + if (seam != NULL && strcmp(seam, "fail_reapply") == 0) { + return NULL; + } + if (is_tty) { + 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); + if (tcsetattr(fd, TCSANOW, &raw) != 0) { + return NULL; + } + } } shared->lease_count++; return shared; } + if (!is_tty) { + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; + memset(&shared->saved_mode, 0, sizeof(shared->saved_mode)); + shared->lease_count = 1; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = true; + shared->is_restored = false; + 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; + } + struct termios mode; if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) { return NULL; @@ -1269,13 +1455,35 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( struct termios raw_mode = mode; php_io_terminal_make_raw_mode(&raw_mode); - if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + const char *seam = php_io_terminal_get_test_seam(); + if ((seam != NULL && strcmp(seam, "fail_first_acquire") == 0) || tcsetattr(fd, TCSANOW, &raw_mode) != 0) { php_io_terminal_close_native_stream(restore_stream); return NULL; } struct termios actual_mode; - if (tcgetattr(fd, &actual_mode) == 0 && (actual_mode.c_lflag & (ECHO | ICANON)) != 0) { + if ((seam != NULL && strcmp(seam, "fail_postcondition") == 0) || + (tcgetattr(fd, &actual_mode) == 0 && (actual_mode.c_lflag & (ECHO | ICANON)) != 0)) { + /* Postcondition failure: roll back to original saved mode before closing restore stream */ + if (tcsetattr(fd, TCSANOW, &mode) != 0) { + /* Rollback failed: preserve restoration state so cleanup or retry can restore it */ + shared = emalloc(sizeof(*shared)); + shared->identity = *identity; + shared->restore_stream = restore_stream; + shared->saved_mode = mode; + shared->lease_count = 0; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; + 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 NULL; + } php_io_terminal_close_native_stream(restore_stream); return NULL; } @@ -1285,6 +1493,10 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( shared->restore_stream = restore_stream; shared->saved_mode = mode; shared->lease_count = 1; + shared->refcount = 1; + shared->owner_pid = getpid(); + shared->is_non_tty = false; + shared->is_restored = false; shared->prev = NULL; shared->next = php_io_terminal_active_shared_modes; if (php_io_terminal_active_shared_modes != NULL) { @@ -1312,6 +1524,9 @@ static bool php_io_terminal_stream_size(php_io_terminal_native_stream fd, zend_l return true; } +#endif + +/* Common byte-decoding utilities (used on POSIX and for non-console streams on Windows) */ static size_t php_io_terminal_utf8_sequence_len(unsigned char byte) { @@ -1350,6 +1565,7 @@ static void php_io_terminal_buffer_remove_last_utf8_char(smart_str *secret) ZSTR_LEN(secret->s) = len; } +#ifndef PHP_WIN32 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; @@ -1375,6 +1591,11 @@ static bool php_io_terminal_timespec_subtract_elapsed(struct timespec *remaining static php_poll_ctx *php_io_terminal_create_poll_context(int fd) { + struct stat st; + if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode)) { + return NULL; + } + php_poll_ctx *poll_ctx = php_poll_create(PHP_POLL_BACKEND_AUTO, 0); if (poll_ctx == NULL) { @@ -1390,15 +1611,16 @@ static php_poll_ctx *php_io_terminal_create_poll_context(int fd) return poll_ctx; } +#endif static int php_io_terminal_read_byte( - int fd, + php_io_terminal_native_stream fd, php_stream *stream, - php_poll_ctx *poll_ctx, + void *poll_ctx_ptr, unsigned char *byte, - const struct timespec *timeout, + const void *timeout_ptr, bool allow_resize, - const sig_atomic_t *generation + const void *generation_ptr ) { if (stream != NULL && stream->writepos > stream->readpos) { @@ -1409,7 +1631,175 @@ static int php_io_terminal_read_byte( return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; } +#ifdef PHP_WIN32 + (void) poll_ctx_ptr; + (void) allow_resize; + (void) generation_ptr; + + HANDLE h = fd; + if ((h == INVALID_HANDLE_VALUE || h == NULL) && stream != NULL) { + h = php_io_terminal_native_stream_from_php_stream(stream); + } + + if (h != INVALID_HANDLE_VALUE && h != NULL) { + int opt = 0; + int optlen = sizeof(opt); + if (getsockopt((SOCKET) h, SOL_SOCKET, SO_TYPE, (char *) &opt, &optlen) == 0) { + if (timeout_ptr != NULL) { + const struct timespec *to = (const struct timespec *) timeout_ptr; + uint64_t ms = (uint64_t) to->tv_sec * 1000 + (to->tv_nsec + 999999) / 1000000; + DWORD wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + fd_set rset; + FD_ZERO(&rset); + FD_SET((SOCKET) h, &rset); + struct timeval tv; + tv.tv_sec = (long) (wait_ms / 1000); + tv.tv_usec = (long) ((wait_ms % 1000) * 1000); + int sel_res = select(0, &rset, NULL, NULL, &tv); + if (sel_res == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + if (sel_res < 0) { + return PHP_IO_TERMINAL_READ_ERROR; + } + } + if (stream != NULL) { + 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; + } + int recvd = recv((SOCKET) h, (char *) byte, 1, 0); + if (recvd == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + return recvd == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + } + + if (h != INVALID_HANDLE_VALUE && h != NULL && GetFileType(h) == FILE_TYPE_PIPE) { + DWORD bytes_avail = 0; + DWORD wait_ms = 0; + bool has_timeout = false; + if (timeout_ptr != NULL) { + const struct timespec *to = (const struct timespec *) timeout_ptr; + has_timeout = true; + uint64_t ms = (uint64_t) to->tv_sec * 1000 + (to->tv_nsec + 999999) / 1000000; + wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms; + } + + if (PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL)) { + zend_hrtime_t start_time = zend_hrtime(); + while (true) { + if (bytes_avail > 0) { + break; + } + if (has_timeout) { + if (wait_ms == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + zend_hrtime_t elapsed_ms = (zend_hrtime() - start_time) / 1000000; + if (elapsed_ms >= wait_ms) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + DWORD sleep_ms = (wait_ms - (DWORD) elapsed_ms > 5) ? 5 : (DWORD)(wait_ms - elapsed_ms); + Sleep(sleep_ms); + } else { + Sleep(10); + } + if (!PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL)) { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_PIPE_NOT_CONNECTED) { + return PHP_IO_TERMINAL_READ_EOF; + } + return PHP_IO_TERMINAL_READ_ERROR; + } + } + } else { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_PIPE_NOT_CONNECTED) { + return PHP_IO_TERMINAL_READ_EOF; + } + if (err == ERROR_INVALID_FUNCTION) { + /* Socket descriptor on Windows returns FILE_TYPE_PIPE: use select() */ + fd_set rset; + FD_ZERO(&rset); + FD_SET((SOCKET) h, &rset); + struct timeval tv; + struct timeval *tv_ptr = NULL; + if (has_timeout) { + tv.tv_sec = (long) (wait_ms / 1000); + tv.tv_usec = (long) ((wait_ms % 1000) * 1000); + tv_ptr = &tv; + } + int sel_res = select(0, &rset, NULL, NULL, tv_ptr); + if (sel_res == 0) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + if (sel_res < 0) { + return PHP_IO_TERMINAL_READ_ERROR; + } + } else { + return PHP_IO_TERMINAL_READ_ERROR; + } + } + } + + if (stream != NULL) { + size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + if (read_bytes == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (timeout_ptr != NULL && !php_stream_eof(stream)) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + + if (fd != INVALID_HANDLE_VALUE && fd != NULL) { + DWORD bytes_read = 0; + if (ReadFile(fd, byte, 1, &bytes_read, NULL) && bytes_read == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (timeout_ptr != NULL) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return PHP_IO_TERMINAL_READ_EOF; + } + + return PHP_IO_TERMINAL_READ_ERROR; +#else + php_poll_ctx *poll_ctx = (php_poll_ctx *) poll_ctx_ptr; + const struct timespec *timeout = (const struct timespec *) timeout_ptr; + const sig_atomic_t *generation = (const sig_atomic_t *) generation_ptr; + if (poll_ctx == NULL) { + if (stream != NULL) { + size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + if (read_bytes == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (php_stream_eof(stream)) { + return PHP_IO_TERMINAL_READ_EOF; + } + if (timeout != NULL) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return PHP_IO_TERMINAL_READ_EOF; + } + + if (fd >= 0) { + ssize_t bytes_read = read(fd, byte, 1); + if (bytes_read == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (timeout != NULL) { + return PHP_IO_TERMINAL_READ_TIMEOUT; + } + return bytes_read == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; + } + return PHP_IO_TERMINAL_READ_ERROR; } @@ -1461,6 +1851,17 @@ static int php_io_terminal_read_byte( return PHP_IO_TERMINAL_READ_ERROR; } + if (stream != NULL) { + size_t n = php_stream_read(stream, (char *) byte, 1); + if (n == 1) { + return PHP_IO_TERMINAL_READ_SUCCESS; + } + if (php_stream_eof(stream)) { + return PHP_IO_TERMINAL_READ_EOF; + } + return PHP_IO_TERMINAL_READ_ERROR; + } + ssize_t bytes_read = read(fd, byte, 1); if (bytes_read == 0) { return PHP_IO_TERMINAL_READ_EOF; @@ -1470,12 +1871,13 @@ static int php_io_terminal_read_byte( } return PHP_IO_TERMINAL_READ_SUCCESS; +#endif } static zend_string *php_io_terminal_finish_utf8_sequence( - int fd, + php_io_terminal_native_stream fd, php_stream *stream, - php_poll_ctx *poll_ctx, + void *poll_ctx, php_io_terminal_utf8_pending *pending, bool has_overall_deadline, zend_hrtime_t overall_deadline_ns @@ -1502,8 +1904,12 @@ static zend_string *php_io_terminal_finish_utf8_sequence( 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; +#ifndef PHP_WIN32 + if (fd >= 0 && php_io_terminal_native_stream_is_tty(fd)) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + } +#endif + 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); @@ -1522,10 +1928,10 @@ static zend_string *php_io_terminal_finish_utf8_sequence( } static zend_string *php_io_terminal_key_from_escape_sequence( - int fd, + php_io_terminal_native_stream fd, php_stream *stream, - php_poll_ctx *poll_ctx, - const struct timespec *sequence_timeout, + void *poll_ctx, + const void *sequence_timeout_ptr, bool has_overall_deadline, zend_hrtime_t overall_deadline_ns ) @@ -1534,6 +1940,7 @@ static zend_string *php_io_terminal_key_from_escape_sequence( size_t seq_len = 0; seq[seq_len++] = 0x1b; + const struct timespec *sequence_timeout = (const struct timespec *) sequence_timeout_ptr; 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 = php_io_terminal_saturating_add_deadline(now, seq_timeout_ns); @@ -1549,13 +1956,10 @@ static zend_string *php_io_terminal_key_from_escape_sequence( 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) { + + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT || read_result == PHP_IO_TERMINAL_READ_EOF) { 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; @@ -1569,13 +1973,10 @@ static zend_string *php_io_terminal_key_from_escape_sequence( break; } php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); - - unsigned char next; - read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, &remaining_ts, false, NULL); + 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) { 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; + break; } if (read_result == PHP_IO_TERMINAL_READ_ERROR) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); @@ -1584,7 +1985,7 @@ static zend_string *php_io_terminal_key_from_escape_sequence( break; } - seq[seq_len++] = next; + unsigned char next = seq[seq_len++]; if (next >= 0x40 && next <= 0x7e) { break; } @@ -1600,9 +2001,11 @@ static zend_string *php_io_terminal_key_from_escape_sequence( 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); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return ZSTR_INIT_LITERAL("shifttab", 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[2~")) return ZSTR_INIT_LITERAL("insert", 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); @@ -1627,23 +2030,22 @@ static zend_string *php_io_terminal_key_from_escape_sequence( 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 { + read_result = PHP_IO_TERMINAL_READ_TIMEOUT; + } + 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_ERROR) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; } } @@ -1651,9 +2053,9 @@ static zend_string *php_io_terminal_key_from_escape_sequence( } static zend_string *php_io_terminal_key_from_utf8_sequence( - int fd, + php_io_terminal_native_stream fd, php_stream *stream, - php_poll_ctx *poll_ctx, + void *poll_ctx, unsigned char key, php_io_terminal_utf8_pending *pending, bool has_overall_deadline, @@ -1678,11 +2080,11 @@ static zend_string *php_io_terminal_key_from_utf8_sequence( } static zend_string *php_io_terminal_key_from_byte( - int fd, + php_io_terminal_native_stream fd, php_stream *stream, - php_poll_ctx *poll_ctx, + void *poll_ctx, unsigned char key, - const struct timespec *sequence_timeout, + const void *sequence_timeout, php_io_terminal_utf8_pending *pending, bool has_overall_deadline, zend_hrtime_t overall_deadline_ns @@ -1708,53 +2110,136 @@ static zend_string *php_io_terminal_key_from_byte( } } +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(php_io_terminal_key_ce, "Up"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Right"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Enter"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Escape"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Home"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Delete"); + } + if (zend_string_equals_literal(key, "insert")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Insert"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "PageDown"); + } + if (zend_string_equals_literal(key, "resize")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize"); + } + if (zend_string_equals_literal(key, "shifttab")) { + return zend_enum_get_case_cstr(php_io_terminal_key_ce, "ShiftTab"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F2"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F4"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F6"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F8"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F10"); + } + if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F12"); + } + return NULL; +} + +#ifndef PHP_WIN32 static zend_string *php_io_terminal_read_stream_key( php_io_terminal_native_stream input, php_stream *stream, const struct timespec *timeout, const struct timespec *sequence_timeout, - php_io_terminal_utf8_pending *pending + php_io_terminal_object *intern ) { + php_io_terminal_utf8_pending *pending = &intern->pending_utf8; int fd = input; - 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; - } - + bool is_tty = (fd >= 0 && isatty(fd) == 1); 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; + struct termios raw_mode; + bool mode_changed = false; + php_poll_ctx *poll_ctx = NULL; + + if (is_tty) { + if (tcgetattr(fd, &mode) == 0 && (mode.c_lflag & (ECHO | ICANON)) != 0) { + raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + if (tcsetattr(fd, TCSANOW, &raw_mode) == 0) { + mode_changed = true; + } else { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); + return NULL; + } + } } - 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; + if (fd >= 0) { + poll_ctx = php_io_terminal_create_poll_context(fd); } sig_atomic_t resize_generation = 0; -#if defined(SIGWINCH) - bool resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); -#else bool resize_handler_installed = false; -#endif - - struct termios raw_mode = mode; - php_io_terminal_make_raw_mode(&raw_mode); - bool 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) { - php_io_terminal_restore_resize_handler(); - } -#endif - php_poll_destroy(poll_ctx); - zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); - return NULL; + if (is_tty) { + resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation); } +#endif bool has_overall_deadline = (timeout != NULL); zend_hrtime_t overall_deadline_ns = 0; @@ -1773,20 +2258,40 @@ static zend_string *php_io_terminal_read_stream_key( 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, - resize_handler_installed ? &resize_generation : NULL); - 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, - 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) { - zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + while (1) { + 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); + break; + } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (key == '\n') { + continue; + } + } + result = php_io_terminal_key_from_byte( + fd, stream, poll_ctx, key, sequence_timeout, pending, + has_overall_deadline, overall_deadline_ns + ); + break; + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + if (is_tty) { + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + } else { + /* Non-TTY stream at EOF returns null */ + result = NULL; + } + break; + } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + result = NULL; + break; + } 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); + break; + } } } @@ -1799,7 +2304,9 @@ static zend_string *php_io_terminal_read_stream_key( if (result != NULL) { zend_string_release(result); } - php_poll_destroy(poll_ctx); + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } if (!EG(exception)) { zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); } @@ -1812,7 +2319,10 @@ static zend_string *php_io_terminal_read_stream_key( } #endif - php_poll_destroy(poll_ctx); + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); + } + return result; } @@ -1820,54 +2330,107 @@ static zend_string *php_io_terminal_read_stream_secret( php_io_terminal_native_stream input, php_stream *stream, const struct timespec *timeout, + php_io_terminal_object *intern, bool *timed_out ) { *timed_out = false; int fd = input; - if (fd < 0 || isatty(fd) != 1) { - return NULL; - } - + bool is_tty = (fd >= 0 && isatty(fd) == 1); struct termios mode; - if (tcgetattr(fd, &mode) != 0) { - return NULL; - } + struct termios raw_mode; + bool mode_changed = false; + php_poll_ctx *poll_ctx = NULL; - php_poll_ctx *poll_ctx = php_io_terminal_create_poll_context(fd); - if (poll_ctx == NULL) { - return NULL; + if (is_tty) { + if (tcgetattr(fd, &mode) != 0) { + return NULL; + } + if ((mode.c_lflag & (ECHO | ICANON)) != 0) { + raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + return NULL; + } + mode_changed = true; + } } - struct termios raw_mode = mode; - php_io_terminal_make_raw_mode(&raw_mode); - 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; + if (fd >= 0) { + poll_ctx = php_io_terminal_create_poll_context(fd); } bool has_overall_deadline = (timeout != NULL); zend_hrtime_t overall_deadline_ns = 0; + bool is_positive_timeout = false; if (has_overall_deadline) { overall_deadline_ns = php_io_terminal_saturating_add_deadline( zend_hrtime(), php_io_terminal_timespec_to_ns(timeout) ); + is_positive_timeout = (timeout->tv_sec > 0 || timeout->tv_nsec > 0); } smart_str secret = {0}; bool success = false; + unsigned char key = 0; + + /* If pending UTF-8 bytes exist from previous readKey, process them first */ + if (intern != NULL && intern->pending_utf8.length > 0) { + unsigned char terminating_key = 0; + bool has_terminating_key = false; + + while (intern->pending_utf8.length < intern->pending_utf8.expected) { + unsigned char next_b; + struct timespec cur_ts; + const struct timespec *cur_ts_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, &cur_ts); + cur_ts_ptr = &cur_ts; + } + int r = php_io_terminal_read_byte(fd, stream, poll_ctx, &next_b, cur_ts_ptr, false, NULL); + if (r == PHP_IO_TERMINAL_READ_TIMEOUT) { + *timed_out = true; + goto restore; + } + if (r != PHP_IO_TERMINAL_READ_SUCCESS) { + *timed_out = false; + goto restore; + } + if ((next_b & 0xc0) == 0x80) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = next_b; + } else { + terminating_key = next_b; + has_terminating_key = true; + break; + } + } + + smart_str_appendl(&secret, (char *) intern->pending_utf8.bytes, intern->pending_utf8.length); + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); + + if (has_terminating_key) { + key = terminating_key; + goto process_key; + } + } while (true) { - unsigned char key; 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) { + if (is_positive_timeout) { + *timed_out = true; + goto restore; + } current_timeout.tv_sec = 0; current_timeout.tv_nsec = 0; } else { @@ -1887,9 +2450,27 @@ static zend_string *php_io_terminal_read_stream_secret( } process_key: + if (intern != NULL && intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (key == '\n') { + continue; + } + } + switch (key) { case '\r': case '\n': + if (key == '\r') { + /* CRLF is one terminator. Consume the LF now only if it is already + * visible in the PHP read buffer (no read, no unread). Otherwise defer: + * the next reader on this Terminal skips a single leading LF. A byte is + * never read ahead here, so nothing has to be pushed back. */ + if (stream != NULL && stream->writepos > stream->readpos && stream->readbuf != NULL && stream->readbuf[stream->readpos] == '\n') { + stream->readpos++; + } else if (intern != NULL) { + intern->last_secret_ended_in_cr = true; + } + } success = true; goto restore; case 0x7f: @@ -1920,7 +2501,6 @@ static zend_string *php_io_terminal_read_stream_secret( 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, step_timeout_ptr, false, NULL); if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { @@ -1929,13 +2509,11 @@ static zend_string *php_io_terminal_read_stream_secret( 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 == 0x1b || next == '\r' || next == '\n') { - /* Escape followed immediately by control byte, Enter, or repeated Escape cancels */ *timed_out = false; goto restore; } @@ -1945,6 +2523,10 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { + if (is_positive_timeout) { + *timed_out = true; + goto restore; + } step_timeout.tv_sec = 0; step_timeout.tv_nsec = 0; } else { @@ -1967,15 +2549,16 @@ static zend_string *php_io_terminal_read_stream_secret( 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 (has_overall_deadline && is_positive_timeout && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + 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; } @@ -1990,6 +2573,10 @@ static zend_string *php_io_terminal_read_stream_secret( if (has_overall_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= overall_deadline_ns) { + if (is_positive_timeout) { + *timed_out = true; + goto restore; + } step_timeout.tv_sec = 0; step_timeout.tv_nsec = 0; } else { @@ -2012,22 +2599,22 @@ static zend_string *php_io_terminal_read_stream_secret( goto restore; } if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) { - /* Internal sequence timeout: continue secret input */ break; } *timed_out = false; goto restore; } + if (has_overall_deadline && is_positive_timeout && zend_hrtime() >= overall_deadline_ns) { + *timed_out = true; + 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; } @@ -2076,115 +2663,34 @@ static zend_string *php_io_terminal_read_stream_secret( smart_str_appendl(&secret, (const char *) seq, i); key = invalid_byte; goto process_key; - } - if (i == seq_len) { - smart_str_appendl(&secret, (const char *) seq, i); - } - } - break; - } - } - } - -restore: - if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { - success = false; - *timed_out = false; - } - - php_poll_destroy(poll_ctx); - - if (success) { - return smart_str_extract(&secret); - } - - smart_str_free(&secret); - return NULL; -} -#endif /* !PHP_WIN32 */ - -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(php_io_terminal_key_ce, "Up"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Right"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Enter"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Escape"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Home"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "Delete"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "PageDown"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F1"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F3"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F5"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F7"); - } - if (zend_string_equals_literal(key, "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(php_io_terminal_key_ce, "F9"); + } + if (i == seq_len) { + smart_str_appendl(&secret, (const char *) seq, i); + } + } + break; + } + } } - if (zend_string_equals_literal(key, "f10")) { - return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F10"); + +restore: + if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { + success = false; + *timed_out = false; } - if (zend_string_equals_literal(key, "f11")) { - return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F11"); + + if (poll_ctx != NULL) { + php_poll_destroy(poll_ctx); } - if (zend_string_equals_literal(key, "f12")) { - return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F12"); + + if (success) { + return smart_str_extract(&secret); } + + smart_str_free(&secret); return NULL; } +#endif /* !PHP_WIN32 */ /* Io\Terminal\TerminalSize methods */ PHP_METHOD(Io_Terminal_TerminalSize, __construct) @@ -2217,33 +2723,42 @@ PHP_METHOD(Io_Terminal_TerminalSize, __construct) } } -/* Io\Terminal\SystemModeToken methods */ -PHP_METHOD(Io_Terminal_SystemModeToken, __construct) +/* Io\Terminal\ModeToken methods */ +PHP_METHOD(Io_Terminal_ModeToken, __construct) { - zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemModeToken"); + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\ModeToken"); } -/* Io\Terminal\SystemTerminal methods */ -PHP_METHOD(Io_Terminal_SystemTerminal, __construct) +/* Io\Terminal\Terminal methods */ +PHP_METHOD(Io_Terminal_Terminal, __construct) { - zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemTerminal"); + zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\Terminal"); } -PHP_METHOD(Io_Terminal_SystemTerminal, fromStdio) +PHP_METHOD(Io_Terminal_Terminal, fromStdio) { ZEND_PARSE_PARAMETERS_NONE(); - object_init_ex(return_value, php_io_terminal_system_terminal_ce); + object_init_ex(return_value, php_io_terminal_terminal_ce); 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); + if (php_io_terminal_stream_target_init(NULL, true, &target)) { + bool is_tty = php_io_terminal_native_stream_is_tty(target.native_stream); + if (is_tty) { + intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } else { + intern->identity.is_non_tty_stream = true; + intern->identity.php_stream_ptr = target.php_stream; + if (target.stream_resource != NULL && Z_TYPE_P(target.stream_resource) == IS_RESOURCE) { + intern->identity.stream_res_id = Z_RES_HANDLE_P(target.stream_resource); + } + intern->has_identity = true; + } } } -PHP_METHOD(Io_Terminal_SystemTerminal, fromStreams) +PHP_METHOD(Io_Terminal_Terminal, fromStreams) { zval *input_arg; zval *output_arg = NULL; @@ -2272,7 +2787,7 @@ PHP_METHOD(Io_Terminal_SystemTerminal, fromStreams) } } - object_init_ex(return_value, php_io_terminal_system_terminal_ce); + object_init_ex(return_value, php_io_terminal_terminal_ce); php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value); ZVAL_COPY(&intern->input_stream_val, input_arg); @@ -2282,12 +2797,21 @@ PHP_METHOD(Io_Terminal_SystemTerminal, fromStreams) ZVAL_COPY(&intern->output_stream_val, input_arg); } - if (php_io_terminal_native_stream_is_valid(target.native_stream)) { + bool is_tty = php_io_terminal_native_stream_is_tty(target.native_stream); + if (is_tty) { intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity); + } else { + memset(&intern->identity, 0, sizeof(intern->identity)); + intern->identity.is_non_tty_stream = true; + intern->identity.php_stream_ptr = target.php_stream; + if (target.stream_resource != NULL && Z_TYPE_P(target.stream_resource) == IS_RESOURCE) { + intern->identity.stream_res_id = Z_RES_HANDLE_P(target.stream_resource); + } + intern->has_identity = true; } } -PHP_METHOD(Io_Terminal_SystemTerminal, getSize) +PHP_METHOD(Io_Terminal_Terminal, getSize) { ZEND_PARSE_PARAMETERS_NONE(); @@ -2315,7 +2839,7 @@ PHP_METHOD(Io_Terminal_SystemTerminal, getSize) object_init_with_constructor(return_value, php_io_terminal_terminal_size_ce, 2, params, NULL); } -PHP_METHOD(Io_Terminal_SystemTerminal, enableRawMode) +PHP_METHOD(Io_Terminal_Terminal, enableRawMode) { ZEND_PARSE_PARAMETERS_NONE(); @@ -2326,127 +2850,95 @@ PHP_METHOD(Io_Terminal_SystemTerminal, enableRawMode) RETURN_THROWS(); } - 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(); - } - + bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream); 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(); + memset(&identity, 0, sizeof(identity)); + + if (is_tty) { + 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(); + } + } else { + identity.is_non_tty_stream = true; + identity.php_stream_ptr = stream.php_stream; + if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) { + identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource); + } } intern->identity = identity; intern->has_identity = true; - php_io_terminal_shared_mode *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, + stream.php_stream, + &identity, + is_tty + ); 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, 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); } -/* - * 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 SystemTerminal object that refers to the same underlying terminal identity - * or shared mode record. - * - * Terminal::restoreMode(?ModeToken $mode = null) accepts the - * interface to allow polymorphic userland terminal fakes (e.g. for unit testing). - * 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. - */ -PHP_METHOD(Io_Terminal_SystemTerminal, restoreMode) -{ - zval *mode_token = NULL; +PHP_METHOD(Io_Terminal_Terminal, restoreMode) +{ + zval *mode_token_zv; - 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_START(1, 1) + Z_PARAM_OBJECT_OF_CLASS(mode_token_zv, php_io_terminal_mode_token_ce) ZEND_PARSE_PARAMETERS_END(); php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); + php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token_zv); - if (mode_token != NULL && !Z_ISNULL_P(mode_token)) { - 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(); - } - php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token); - - if (!mode->valid || mode->shared == NULL) { - zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); - RETURN_THROWS(); - } + if (!mode->valid || mode->shared == NULL) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } - bool matches = false; - 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; - php_io_terminal_identity current_identity; + if (mode->owner_pid != getpid()) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); + } - 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); + bool matches = false; + 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)) { + bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream); + if (is_tty && php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) { + matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity); + } else if (!is_tty) { + memset(¤t_identity, 0, sizeof(current_identity)); + current_identity.is_non_tty_stream = true; + current_identity.php_stream_ptr = stream.php_stream; + if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) { + current_identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource); } + matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity); } - - if (!matches) { - zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); - RETURN_THROWS(); - } - - 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(); - } - - 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_TRUE; + } else if (intern->has_identity) { + matches = php_io_terminal_identities_match(&intern->identity, &mode->shared->identity); } - 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); - RETURN_THROWS(); - } - - OBJ_RELEASE(intern->active_mode_token); - intern->active_mode_token = NULL; - - RETURN_TRUE; + if (!matches) { + zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal"); + RETURN_THROWS(); } - RETURN_FALSE; + 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(); + } } -PHP_METHOD(Io_Terminal_SystemTerminal, readKey) +PHP_METHOD(Io_Terminal_Terminal, readKey) { php_date_time_duration *timeout_duration = NULL; php_date_time_duration *seq_timeout_duration = NULL; @@ -2504,14 +2996,85 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readKey) } } - zend_string *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 - ); + DWORD console_mode; + bool is_console = (stream.native_stream != INVALID_HANDLE_VALUE && stream.native_stream != NULL && GetConsoleMode(stream.native_stream, &console_mode) != 0); + zend_string *key = NULL; + + if (is_console) { + key = php_io_terminal_read_console_key( + stream.native_stream, + wait_ms, + &intern->pending_high_surrogate, + &intern->pending_key, + &intern->pending_key_high_surrogate + ); + } else { + /* Non-console stream on Windows: use byte decoding */ + struct timespec win_timeout; + struct timespec win_seq_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; + const struct timespec *win_timeout_ptr = NULL; + bool win_is_non_blocking = false; + + if (timeout_duration != NULL) { + win_is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); + win_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + win_timeout.tv_nsec = timeout_duration->duration.nanoseconds; + win_timeout_ptr = &win_timeout; + } + + if (win_is_non_blocking) { + win_seq_timeout.tv_sec = 0; + win_seq_timeout.tv_nsec = 0; + } else if (seq_timeout_duration != NULL) { + win_seq_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); + win_seq_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; + } + + bool has_overall_deadline = (win_timeout_ptr != NULL); + zend_hrtime_t overall_deadline_ns = 0; + if (has_overall_deadline) { + overall_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(win_timeout_ptr) + ); + } + + if (intern->pending_utf8.length > 0) { + key = php_io_terminal_finish_utf8_sequence( + stream.native_stream, stream.php_stream, NULL, &intern->pending_utf8, + has_overall_deadline, overall_deadline_ns + ); + } else { + while (1) { + unsigned char byte; + int read_result = php_io_terminal_read_byte( + stream.native_stream, stream.php_stream, NULL, &byte, win_timeout_ptr, false, NULL + ); + if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (byte == '\n') { + continue; + } + } + key = php_io_terminal_key_from_byte( + stream.native_stream, stream.php_stream, NULL, byte, &win_seq_timeout, &intern->pending_utf8, + has_overall_deadline, overall_deadline_ns + ); + break; + } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { + key = NULL; + break; + } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + key = NULL; + break; + } 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); + break; + } + } + } + } #else struct timespec timeout; struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; @@ -2538,7 +3101,7 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readKey) stream.php_stream, timeout_ptr, &sequence_timeout, - &intern->pending_utf8 + intern ); #endif @@ -2593,12 +3156,16 @@ static zend_string *php_io_terminal_read_line_windows( } return NULL; } + bool remainder_is_lf = (line_len == 1 && buf[0] == '\n'); 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'; + if (remainder_is_lf && intern != NULL && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') { + intern->pending_utf8.length--; + } zend_string *res = zend_string_init(buf, line_len, 0); efree(buf); return res; @@ -2621,12 +3188,16 @@ static zend_string *php_io_terminal_read_line_windows( } return NULL; } + bool remainder_is_lf = (line_len == 1 && buf[0] == '\n'); 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'; + if (remainder_is_lf && intern != NULL && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') { + intern->pending_utf8.length--; + } zend_string *res = zend_string_init(buf, line_len, 0); efree(buf); return res; @@ -2879,7 +3450,6 @@ static char *php_io_terminal_read_line_posix( *is_eof = true; return NULL; } - /* Read error */ return NULL; } return buf; @@ -2946,9 +3516,6 @@ static char *php_io_terminal_read_line_posix( } #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; } @@ -2992,7 +3559,7 @@ static char *php_io_terminal_read_line_posix( } #endif -PHP_METHOD(Io_Terminal_SystemTerminal, readLine) +PHP_METHOD(Io_Terminal_Terminal, readLine) { ZEND_PARSE_PARAMETERS_NONE(); @@ -3003,16 +3570,46 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readLine) 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); + bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream); + if (!intern->has_identity) { + if (is_tty) { + intern->has_identity = php_io_terminal_get_identity(stream.native_stream, &intern->identity); + } else { + memset(&intern->identity, 0, sizeof(intern->identity)); + intern->identity.is_non_tty_stream = true; + intern->identity.php_stream_ptr = stream.php_stream; + if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) { + intern->identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource); + } + intern->has_identity = true; + } } - 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(); + if (intern->has_identity && is_tty) { + php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(&intern->identity); + if (shared != NULL && !shared->is_non_tty && !shared->is_restored && shared->owner_pid == getpid()) { + 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 + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (stream.php_stream != NULL) { + if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + int ch = php_stream_getc(stream.php_stream); + if (ch != '\n' && ch != EOF) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; + } + } + } + } + zend_string *line = php_io_terminal_read_line_windows(stream.native_stream, stream.php_stream, intern); if (EG(exception)) { if (line != NULL) { @@ -3021,10 +3618,44 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readLine) RETURN_THROWS(); } if (line == NULL) { + 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 (intern->pending_utf8.length > 0) { + size_t total_len = intern->pending_utf8.length + ZSTR_LEN(line); + zend_string *res = zend_string_alloc(total_len, 0); + memcpy(ZSTR_VAL(res), intern->pending_utf8.bytes, intern->pending_utf8.length); + memcpy(ZSTR_VAL(res) + intern->pending_utf8.length, ZSTR_VAL(line), ZSTR_LEN(line)); + ZSTR_VAL(res)[total_len] = '\0'; + intern->pending_utf8.length = 0; + intern->pending_utf8.expected = 0; + zend_string_release(line); + RETURN_STR(res); + } RETURN_STR(line); #else + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (stream.php_stream != NULL) { + if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + int ch = php_stream_getc(stream.php_stream); + if (ch != '\n' && ch != EOF) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; + } + } + } + } + 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); @@ -3052,6 +3683,7 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readLine) } /* Strip line ending */ + bool remainder_is_lf = (out_len == 1 && buf[0] == '\n'); 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') { @@ -3059,6 +3691,10 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readLine) } buf[out_len] = '\0'; + if (remainder_is_lf && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') { + intern->pending_utf8.length--; + } + zend_string *res; if (intern->pending_utf8.length > 0) { size_t total_len = intern->pending_utf8.length + out_len; @@ -3078,7 +3714,7 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readLine) #endif } -PHP_METHOD(Io_Terminal_SystemTerminal, readSecret) +PHP_METHOD(Io_Terminal_Terminal, readSecret) { php_date_time_duration *timeout_duration = NULL; @@ -3094,14 +3730,6 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readSecret) 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)); - intern->pending_key_high_surrogate = 0; - intern->pending_high_surrogate = 0; -#else - 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(); @@ -3124,15 +3752,268 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readSecret) 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, - wait_ms, - has_timeout, - &timed_out - ); + + DWORD console_mode; + bool is_console = (stream.native_stream != INVALID_HANDLE_VALUE && stream.native_stream != NULL && GetConsoleMode(stream.native_stream, &console_mode) != 0); + zend_string *secret = NULL; + + if (is_console) { + secret = php_io_terminal_read_console_secret( + stream.native_stream, + &intern->pending_key, + &intern->pending_key_high_surrogate, + &intern->pending_high_surrogate, + wait_ms, + has_timeout, + &timed_out + ); + } else { + /* Non-console stream on Windows */ + smart_str sec_str = {0}; + bool success = false; + unsigned char byte = 0; + struct timespec win_sec_timeout; + const struct timespec *win_sec_timeout_ptr = NULL; + bool win_has_deadline = false; + zend_hrtime_t win_deadline_ns = 0; + bool win_is_positive_timeout = false; + + if (timeout_duration != NULL) { + win_sec_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); + win_sec_timeout.tv_nsec = timeout_duration->duration.nanoseconds; + win_sec_timeout_ptr = &win_sec_timeout; + win_has_deadline = true; + win_deadline_ns = php_io_terminal_saturating_add_deadline( + zend_hrtime(), + php_io_terminal_timespec_to_ns(&win_sec_timeout) + ); + win_is_positive_timeout = (win_sec_timeout.tv_sec > 0 || win_sec_timeout.tv_nsec > 0); + } + + /* If pending UTF-8 bytes exist from previous readKey, process them first */ + if (intern->pending_utf8.length > 0) { + unsigned char terminating_key = 0; + bool has_terminating_key = false; + + while (intern->pending_utf8.length < intern->pending_utf8.expected) { + unsigned char next_b; + struct timespec cur_ts; + const struct timespec *cur_ts_ptr = NULL; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_ts); + cur_ts_ptr = &cur_ts; + } + int r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, cur_ts_ptr, false, NULL); + if (r == PHP_IO_TERMINAL_READ_TIMEOUT) { + timed_out = true; + goto win_secret_done; + } + if (r != PHP_IO_TERMINAL_READ_SUCCESS) { + timed_out = false; + goto win_secret_done; + } + if ((next_b & 0xc0) == 0x80) { + intern->pending_utf8.bytes[intern->pending_utf8.length++] = next_b; + } else { + terminating_key = next_b; + has_terminating_key = true; + break; + } + } + + smart_str_appendl(&sec_str, (char *) intern->pending_utf8.bytes, intern->pending_utf8.length); + memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8)); + + if (has_terminating_key) { + byte = terminating_key; + goto win_process_key; + } + } + + while (true) { + struct timespec cur_to; + const struct timespec *cur_to_ptr = NULL; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + break; + } + cur_to.tv_sec = 0; + cur_to.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_to); + } + cur_to_ptr = &cur_to; + } + int read_result = php_io_terminal_read_byte( + stream.native_stream, stream.php_stream, NULL, &byte, cur_to_ptr, false, NULL + ); + if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { + timed_out = true; + break; + } + if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) { + timed_out = false; + break; + } + +win_process_key: + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (byte == '\n') { + continue; + } + } + + if (byte == '\r' || byte == '\n') { + if (byte == '\r') { + if (stream.php_stream != NULL && stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + intern->last_secret_ended_in_cr = true; + } + } + success = true; + break; + } + if (byte == 0x7f || byte == 0x08) { + php_io_terminal_buffer_remove_last_utf8_char(&sec_str); + continue; + } + if (byte == 0x03 || byte == 0x04) { + timed_out = false; + break; + } + if (byte == 0x1b) { + struct timespec esc_to; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char next_b; + int rnext = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, &esc_to, false, NULL); + if (rnext != PHP_IO_TERMINAL_READ_SUCCESS) { + /* Standalone Escape cancels */ + timed_out = false; + goto win_secret_done; + } + if (next_b == 0x03 || next_b == 0x04 || next_b == 0x1b || next_b == '\r' || next_b == '\n') { + timed_out = false; + goto win_secret_done; + } + if (next_b == '[') { + while (true) { + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + goto win_secret_done; + } + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char csi_ch; + int csi_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &csi_ch, &esc_to, false, NULL); + if (csi_r != PHP_IO_TERMINAL_READ_SUCCESS) { + if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + break; + } + if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + if (csi_ch == 0x03 || csi_ch == 0x04 || csi_ch == 0x1b || csi_ch == '\r' || csi_ch == '\n') { + timed_out = false; + goto win_secret_done; + } + if (csi_ch >= 0x40 && csi_ch <= 0x7e) break; + } + continue; + } + if (next_b == 'O') { + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + goto win_secret_done; + } + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char ss3_ch; + int ss3_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &ss3_ch, &esc_to, false, NULL); + if (ss3_r != PHP_IO_TERMINAL_READ_SUCCESS) { + if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + continue; + } + if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + if (ss3_ch == 0x03 || ss3_ch == 0x04 || ss3_ch == 0x1b || ss3_ch == '\r' || ss3_ch == '\n') { + timed_out = false; + goto win_secret_done; + } + continue; + } + continue; + } + smart_str_appendc(&sec_str, (char) byte); + } + +win_secret_done: + if (success) { + secret = smart_str_extract(&sec_str); + } else { + smart_str_free(&sec_str); + } + } #else struct timespec timeout; const struct timespec *timeout_ptr = NULL; @@ -3141,7 +4022,9 @@ PHP_METHOD(Io_Terminal_SystemTerminal, readSecret) 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); + zend_string *secret = php_io_terminal_read_stream_secret( + stream.native_stream, stream.php_stream, timeout_ptr, intern, &timed_out + ); #endif if (secret != NULL) { RETURN_STR(secret); @@ -3174,17 +4057,14 @@ 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_terminal_ce = register_class_Io_Terminal_Terminal(); - - 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; + 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_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; + php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal(); + 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); php_io_terminal_object_handlers.free_obj = php_io_terminal_free_obj; @@ -3197,7 +4077,7 @@ PHP_MINIT_FUNCTION(terminal) # endif # ifndef PHP_IO_TERMINAL_HAVE_PTSNAME_R php_io_terminal_ptsname_mutex = tsrm_mutex_alloc(); -# endif +# endif # endif #endif @@ -3236,8 +4116,12 @@ PHP_RSHUTDOWN_FUNCTION(terminal) { 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); + php_io_terminal_remove_active_shared_mode(shared); + if (shared->owner_pid == getpid() && !shared->is_non_tty && !shared->is_restored) { + php_io_terminal_restore_shared_mode(shared); + shared->is_restored = true; + } + php_io_terminal_shared_mode_delref(shared); } return SUCCESS; } diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index 1e56d7eef25d..ca405fde4140 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -23,9 +23,11 @@ enum Key case Home; case End; case Delete; + case Insert; case PageUp; case PageDown; case Resize; + case ShiftTab; case F1; case F2; case F3; @@ -49,33 +51,11 @@ enum Key public function __construct(int $cols, int $rows) {} } - interface ModeToken {} - - interface Terminal - { - public function getSize(): ?TerminalSize; - - public function enableRawMode(): ModeToken; - - public function restoreMode(?ModeToken $mode = null): bool; - - public function readKey( - ?\Time\Duration $timeout = null, - ?\Time\Duration $sequenceTimeout = null, - ): Key|string|null; - - public function readLine(): ?string; - - public function readSecret( - ?\Time\Duration $timeout = null, - ): ?string; - } - /** * @strict-properties * @not-serializable */ - final class SystemModeToken implements ModeToken + final class ModeToken { private function __construct() {} } @@ -84,11 +64,11 @@ private function __construct() {} * @strict-properties * @not-serializable */ - final class SystemTerminal implements Terminal + final class Terminal { private function __construct() {} - public static function fromStdio(): SystemTerminal {} + public static function fromStdio(): Terminal {} /** * @param resource $input @@ -97,15 +77,15 @@ public static function fromStdio(): SystemTerminal {} public static function fromStreams( $input, $output = null, - ): SystemTerminal {} + ): Terminal {} public function getSize(): ?TerminalSize {} - public function enableRawMode(): SystemModeToken {} + public function enableRawMode(): ModeToken {} public function restoreMode( - ?ModeToken $mode = null, - ): bool {} + ModeToken $mode, + ): void {} public function readKey( ?\Time\Duration $timeout = null, diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index cdd7b7dffb5f..3b5cdc543f8c 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: c963fd0c25f8d397c4c3b1d47e16258e6201c8cf + * Stub hash: 701a9256c6116cb9161669791188bdf8d7295ba8 * Has decl header: yes */ #include "zend_enum.h" @@ -9,14 +9,27 @@ 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_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_ModeToken___construct + +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) + ZEND_ARG_INFO(0, input) + ZEND_ARG_INFO_WITH_DEFAULT_VALUE(0, output, "null") +ZEND_END_ARG_INFO() + 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_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_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_restoreMode, 0, 1, IS_VOID, 0) + ZEND_ARG_OBJ_INFO(0, mode, Io\\Terminal\\ModeToken, 0) ZEND_END_ARG_INFO() 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) @@ -31,74 +44,38 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_readS 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_SystemModeToken___construct, 0, 0, 0) -ZEND_END_ARG_INFO() - -#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_SystemTerminal_fromStdio, 0, 0, Io\\Terminal\\SystemTerminal, 0) -ZEND_END_ARG_INFO() - -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_SystemTerminal_getSize arginfo_class_Io_Terminal_Terminal_getSize - -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_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_SystemTerminal_readLine arginfo_class_Io_Terminal_Terminal_readLine - -#define arginfo_class_Io_Terminal_SystemTerminal_readSecret arginfo_class_Io_Terminal_Terminal_readSecret - ZEND_METHOD(Io_Terminal_TerminalSize, __construct); -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); +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, readLine); +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_PUBLIC) ZEND_FE_END }; -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_SystemModeToken_methods[] = { - ZEND_ME(Io_Terminal_SystemModeToken, __construct, arginfo_class_Io_Terminal_SystemModeToken___construct, ZEND_ACC_PRIVATE) +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 }; -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) +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, readLine, arginfo_class_Io_Terminal_Terminal_readLine, ZEND_ACC_PUBLIC) + ZEND_ME(Io_Terminal_Terminal, readSecret, arginfo_class_Io_Terminal_Terminal_readSecret, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -138,12 +115,16 @@ static zend_class_entry *register_class_Io_Terminal_Key(void) zend_enum_add_case_cstr(class_entry, "Delete", NULL); + zend_enum_add_case_cstr(class_entry, "Insert", NULL); + zend_enum_add_case_cstr(class_entry, "PageUp", NULL); zend_enum_add_case_cstr(class_entry, "PageDown", NULL); zend_enum_add_case_cstr(class_entry, "Resize", NULL); + zend_enum_add_case_cstr(class_entry, "ShiftTab", NULL); + zend_enum_add_case_cstr(class_entry, "F1", NULL); zend_enum_add_case_cstr(class_entry, "F2", NULL); @@ -197,8 +178,8 @@ static zend_class_entry *register_class_Io_Terminal_ModeToken(void) { zend_class_entry ce, *class_entry; - INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "ModeToken", NULL); - class_entry = zend_register_internal_interface(&ce); + 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); return class_entry; } @@ -208,29 +189,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_interface(&ce); - - return class_entry; -} - -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", "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_ModeToken); - - return class_entry; -} - -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", "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_Terminal); return class_entry; } diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index eee8aa91fb2e..0cac98609ff9 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: c963fd0c25f8d397c4c3b1d47e16258e6201c8cf */ + * Stub hash: 701a9256c6116cb9161669791188bdf8d7295ba8 */ -#ifndef ZEND_IO_TERMINAL_DECL_c963fd0c25f8d397c4c3b1d47e16258e6201c8cf_H -#define ZEND_IO_TERMINAL_DECL_c963fd0c25f8d397c4c3b1d47e16258e6201c8cf_H +#ifndef ZEND_IO_TERMINAL_DECL_701a9256c6116cb9161669791188bdf8d7295ba8_H +#define ZEND_IO_TERMINAL_DECL_701a9256c6116cb9161669791188bdf8d7295ba8_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -16,21 +16,23 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Home = 9, ZEND_ENUM_Io_Terminal_Key_End = 10, ZEND_ENUM_Io_Terminal_Key_Delete = 11, - ZEND_ENUM_Io_Terminal_Key_PageUp = 12, - ZEND_ENUM_Io_Terminal_Key_PageDown = 13, - ZEND_ENUM_Io_Terminal_Key_Resize = 14, - ZEND_ENUM_Io_Terminal_Key_F1 = 15, - ZEND_ENUM_Io_Terminal_Key_F2 = 16, - ZEND_ENUM_Io_Terminal_Key_F3 = 17, - ZEND_ENUM_Io_Terminal_Key_F4 = 18, - ZEND_ENUM_Io_Terminal_Key_F5 = 19, - ZEND_ENUM_Io_Terminal_Key_F6 = 20, - ZEND_ENUM_Io_Terminal_Key_F7 = 21, - ZEND_ENUM_Io_Terminal_Key_F8 = 22, - ZEND_ENUM_Io_Terminal_Key_F9 = 23, - ZEND_ENUM_Io_Terminal_Key_F10 = 24, - ZEND_ENUM_Io_Terminal_Key_F11 = 25, - ZEND_ENUM_Io_Terminal_Key_F12 = 26, + ZEND_ENUM_Io_Terminal_Key_Insert = 12, + ZEND_ENUM_Io_Terminal_Key_PageUp = 13, + ZEND_ENUM_Io_Terminal_Key_PageDown = 14, + ZEND_ENUM_Io_Terminal_Key_Resize = 15, + ZEND_ENUM_Io_Terminal_Key_ShiftTab = 16, + ZEND_ENUM_Io_Terminal_Key_F1 = 17, + ZEND_ENUM_Io_Terminal_Key_F2 = 18, + ZEND_ENUM_Io_Terminal_Key_F3 = 19, + ZEND_ENUM_Io_Terminal_Key_F4 = 20, + ZEND_ENUM_Io_Terminal_Key_F5 = 21, + ZEND_ENUM_Io_Terminal_Key_F6 = 22, + ZEND_ENUM_Io_Terminal_Key_F7 = 23, + ZEND_ENUM_Io_Terminal_Key_F8 = 24, + ZEND_ENUM_Io_Terminal_Key_F9 = 25, + ZEND_ENUM_Io_Terminal_Key_F10 = 26, + ZEND_ENUM_Io_Terminal_Key_F11 = 27, + ZEND_ENUM_Io_Terminal_Key_F12 = 28, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_c963fd0c25f8d397c4c3b1d47e16258e6201c8cf_H */ +#endif /* ZEND_IO_TERMINAL_DECL_701a9256c6116cb9161669791188bdf8d7295ba8_H */ diff --git a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt b/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt new file mode 100644 index 000000000000..8c4ff71cf33b --- /dev/null +++ b/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt @@ -0,0 +1,47 @@ +--TEST-- +Io\Terminal\Terminal: separate Terminal wrappers own separate decoder state +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); +var_dump($k1 === null); + +// Terminal 2 now attempts to read the stream without $term1's decoder state +fwrite($fp, "\x82\xAC"); +fseek($fp, 1); + +// $term2 sees continuation bytes without the initial prefix +$k2 = $term2->readKey(Duration::fromSeconds(0)); +// $term2 cannot assemble the € symbol because it does not share $term1's pending buffer +var_dump($k2 !== "€"); + +// $term1, given its continuation, assembles the full sequence +fseek($fp, 1); +$k1_res = $term1->readKey(Duration::fromSeconds(0)); +var_dump($k1_res === "€"); + +fclose($fp); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_controlling_tty.phpt b/ext/standard/tests/terminal/terminal_controlling_tty.phpt index f1627b53cfdf..cb0445b1f5df 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\SystemTerminal: /dev/tty controlling terminal identity +Io\Terminal\Terminal: /dev/tty controlling terminal identity --SKIPIF-- enableRawMode(); var_dump($mTty instanceof ModeToken); // Descriptors to the same controlling terminal share identity -var_dump($tTty2->restoreMode($mTty)); +$tTty2->restoreMode($mTty); +echo "restored via tTty2 +"; -// Mode was already restored; further restore returns false -var_dump($tTty1->restoreMode()); +// Mode was already restored; reusing the token throws ValueError +try { + $tTty1->restoreMode($mTty); + echo "FAIL: consumed token was accepted +"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), " +"; +} fclose($ttyFp2); fclose($ttyFp1); ?> --EXPECT-- bool(true) -bool(true) -bool(false) +restored via tTty2 +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_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index c211fc9ea803..9ddf03b35ad9 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -1,39 +1,39 @@ --TEST-- -Io\Terminal\SystemTerminal: fromStdio, fromStreams, construction and serialization restrictions +Io\Terminal\Terminal: fromStdio, fromStreams, construction and serialization restrictions --FILE-- getMessage(), PHP_EOL; } try { - SystemTerminal::fromStreams($fp, 123); + Terminal::fromStreams($fp, 123); } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Constructor privacy for SystemTerminal and SystemModeToken -foreach ([SystemTerminal::class, SystemModeToken::class] as $class) { +// Constructor privacy for Terminal and ModeToken +foreach ([Terminal::class, ModeToken::class] as $class) { try { new $class(); } catch (Throwable $e) { @@ -79,7 +79,7 @@ foreach ([$t1, $t2] as $obj) { } } -// Serialization rejection for SystemTerminal +// Serialization rejection for Terminal try { serialize($t1); } catch (Throwable $e) { @@ -88,16 +88,16 @@ try { ?> --EXPECT-- -object(Io\Terminal\SystemTerminal)#1 (0) { +object(Io\Terminal\Terminal)#1 (0) { } -object(Io\Terminal\SystemTerminal)#2 (0) { +object(Io\Terminal\Terminal)#2 (0) { } -object(Io\Terminal\SystemTerminal)#3 (0) { +object(Io\Terminal\Terminal)#3 (0) { } -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 +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 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\SystemTerminal -Error: Trying to clone an uncloneable object of class Io\Terminal\SystemTerminal -Exception: Serialization of 'Io\Terminal\SystemTerminal' is not allowed +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 index ce8c8f75c681..205b3fea6161 100644 --- a/ext/standard/tests/terminal/terminal_identity.phpt +++ b/ext/standard/tests/terminal/terminal_identity.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\SystemTerminal: POSIX terminal identity and unrelated PTY rejection +Io\Terminal\Terminal: POSIX terminal identity and unrelated PTY rejection --SKIPIF-- enableRawMode(); var_dump($m1_dup instanceof ModeToken); -var_dump($t1_dup->restoreMode($m1_dup)); +$t1_dup->restoreMode($m1_dup); +echo "restored via t1_dup +"; $m1 = $t1->enableRawMode(); try { $t2->restoreMode($m1); - echo "FAIL: unrelated PTY accepted token\n"; + echo "FAIL: unrelated PTY accepted token +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -var_dump($t1->restoreMode($m1)); -var_dump($t1->restoreMode()); +$t1->restoreMode($m1); +echo "restored via t1 +"; + +try { + $t1->restoreMode($m1); + echo "FAIL: consumed token accepted +"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} // 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($pipes1[0], "exit +"); unset($t1, $t1_dup); foreach ($pipes1 as $pipe) { if (is_resource($pipe)) fclose($pipe); @@ -92,16 +105,19 @@ $proc3 = proc_open( ], $pipes3, ); -$t3 = SystemTerminal::fromStreams($pipes3[0]); +$t3 = Terminal::fromStreams($pipes3[0]); try { $t3->restoreMode($m_reuse); - echo "FAIL: reused fd accepted token\n"; + echo "FAIL: reused fd accepted token +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -fwrite($pipes2[0], "exit\n"); -fwrite($pipes3[0], "exit\n"); +fwrite($pipes2[0], "exit +"); +fwrite($pipes3[0], "exit +"); unset($t2, $t3); foreach ($pipes2 as $pipe) { if (is_resource($pipe)) fclose($pipe); @@ -114,8 +130,8 @@ proc_close($proc3); ?> --EXPECT-- bool(true) -bool(true) -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\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +restored via t1_dup +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +restored via t1 +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +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_interface_mock.phpt b/ext/standard/tests/terminal/terminal_interface_mock.phpt index b3b4521c58ac..9097dddbbdc8 100644 --- a/ext/standard/tests/terminal/terminal_interface_mock.phpt +++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt @@ -1,89 +1,60 @@ --TEST-- -Io\Terminal\Terminal: mockable terminal boundary and foreign token rejection +Io\Terminal\Terminal: final class boundary, dependency injection, and testing with streams --FILE-- implementsInterface(Terminal::class)); +// Verify final class design (no interfaces) +$rcTerm = new ReflectionClass(Terminal::class); +var_dump($rcTerm->isFinal()); +var_dump($rcTerm->isInterface()); -$rcToken = new ReflectionClass(SystemModeToken::class); -var_dump($rcToken->implementsInterface(ModeToken::class)); +$rcToken = new ReflectionClass(ModeToken::class); +var_dump($rcToken->isFinal()); +var_dump($rcToken->isInterface()); -// Application-level fake -class FakeModeToken implements ModeToken {} - -class FakeTerminal implements Terminal +// Consumer accepts Terminal directly via dependency injection +function readUserCommand(Terminal $term): string { - public bool $isRaw = false; - - public function getSize(): ?TerminalSize - { - return new TerminalSize(120, 40); - } - - public function enableRawMode(): ModeToken - { - $this->isRaw = true; - return new FakeModeToken(); - } - - public function restoreMode(?ModeToken $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 readLine(): ?string - { - return "mocked-line"; - } - - public function readSecret(?Duration $timeout = null): ?string - { - return "mocked-secret"; - } -} - -// Application service consuming Terminal -function promptPassword(Terminal $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'); + $key = $term->readKey(Duration::fromSeconds(1)); + if ($key instanceof Key) { + return "key:" . $key->name; + } + return "char:" . ($key ?? "none"); } finally { $term->restoreMode($token); } } -$fake = new FakeTerminal(); -echo promptPassword($fake), PHP_EOL; -var_dump($fake->isRaw); +// Testing with php://memory stream in pure PHP (Larry & Tim testing approach) +$in = fopen('php://memory', 'w+'); +fwrite($in, ""); // Key::Up +rewind($in); + +$term = Terminal::fromStreams($in); +echo readUserCommand($term), PHP_EOL; -// Native restoreMode rejects foreign ModeToken implementations -$fp = fopen('php://temp', 'r+'); -$native = SystemTerminal::fromStreams($fp); +// Non-tty raw mode token is valid ModeToken and restores cleanly +$memStream = fopen('php://memory', 'w+'); +$memTerm = Terminal::fromStreams($memStream); +$token = $memTerm->enableRawMode(); +var_dump($token instanceof ModeToken); +$memTerm->restoreMode($token); +echo "restored successfully +"; +// Reusing consumed token throws ValueError try { - $native->restoreMode(new FakeModeToken()); - echo "FAIL: native accepted foreign ModeToken\n"; + $memTerm->restoreMode($token); + echo "FAIL: accepted consumed token +"; } catch (ValueError $e) { echo "Caught: ", $e->getMessage(), PHP_EOL; } @@ -91,7 +62,10 @@ try { ?> --EXPECT-- bool(true) +bool(false) bool(true) -size=120x40 secret=mocked-secret bool(false) -Caught: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +key:Up +bool(true) +restored successfully +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_key_line_handoff.phpt b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt new file mode 100644 index 000000000000..57f5be3142b7 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt @@ -0,0 +1,47 @@ +--TEST-- +Io\Terminal\Terminal: pending UTF-8 handoff from readKey to readLine +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); + fwrite($fp, $continuation); + fseek($fp, 1); + return [$term, $fp]; +} + +// 1. Complete UTF-8 sequence: \xE2 + \x82\xACline\n => €line +[$t1, $fp1] = stageLineHandoff("\x82\xACline\n"); +$line1 = $t1->readLine(); +var_dump($line1 === "€line"); +fclose($fp1); + +// 2. Incomplete UTF-8 at EOF: \xE2 at EOF returns pending byte +[$t2, $fp2] = stageLineHandoff(""); +$line2 = $t2->readLine(); +var_dump($line2 === "\xE2"); +fclose($fp2); + +// 3. Malformed UTF-8: \xE2 followed by non-continuation ASCII +[$t3, $fp3] = stageLineHandoff("Xline\n"); +$line3 = $t3->readLine(); +var_dump($line3 === "\xE2Xline"); +fclose($fp3); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt new file mode 100644 index 000000000000..4703430b38a3 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt @@ -0,0 +1,86 @@ +--TEST-- +Io\Terminal\Terminal: pending UTF-8 handoff from readKey to readSecret +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); + // Verify \xE2 timed out and is retained + if ($k !== null) { + echo "Unexpected key: " . bin2hex($k) . "\n"; + } + fwrite($fp, $continuation); + fseek($fp, 1); + return $term; +} + +// 1. Valid continuation: \xE2 + \x82\xAC + pass\n => €pass +$t1 = stageHandoff("\x82\xACpass\n"); +$s1 = $t1->readSecret(); +var_dump($s1 === "€pass"); + +// 2. Non-continuation Ctrl+C: must trigger cancellation, NOT swallow \x03 into secret +$t2 = stageHandoff("\x03\n"); +try { + $s2 = $t2->readSecret(); + echo "FAIL: Ctrl+C swallowed into secret: " . bin2hex($s2) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+C cancelled after incomplete UTF-8\n"; +} + +// 3. Non-continuation Ctrl+D: must trigger cancellation, NOT swallow \x04 into secret +$t3 = stageHandoff("\x04\n"); +try { + $s3 = $t3->readSecret(); + echo "FAIL: Ctrl+D swallowed into secret: " . bin2hex($s3) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+D cancelled after incomplete UTF-8\n"; +} + +// 4. Non-continuation Escape: must trigger cancellation, NOT swallow \x1B into secret +$t4 = stageHandoff("\x1b\n"); +try { + $s4 = $t4->readSecret(); + echo "FAIL: Escape swallowed into secret: " . bin2hex($s4) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Escape cancelled after incomplete UTF-8\n"; +} + +// 5. Non-continuation Enter: must terminate secret, NOT swallow newline into secret text +$t5 = stageHandoff("\nsecond\n"); +try { + $s5 = $t5->readSecret(); + // The secret must be terminated by the first newline + var_dump($s5 !== null && strpos($s5, "\n") === false); +} catch (TerminalException $e) { + // Or throw if invalid UTF-8 at terminator + echo "PASS: Enter terminated/threw on incomplete UTF-8\n"; +} + +// 6. Non-continuation Backspace: must not appear in secret content +$t6 = stageHandoff("\x08pass\n"); +$s6 = $t6->readSecret(); +var_dump(strpos($s6, "\x08") === false); + +?> +--EXPECTF-- +bool(true) +PASS: Ctrl+C cancelled after incomplete UTF-8 +PASS: Ctrl+D cancelled after incomplete UTF-8 +PASS: Escape cancelled after incomplete UTF-8 +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 index 3a2cd94cd5d6..d8d6fe43986e 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\SystemTerminal: overlapping raw-mode sessions, ownership validation, and out-of-order restoration +Io\Terminal\Terminal: overlapping raw-mode sessions, ownership validation, and out-of-order restoration --SKIPIF-- $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) return false; + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); + return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false; +} + // Two separate Terminal instances referencing descriptors on the same PTY -$t1 = SystemTerminal::fromStreams($pipes[0]); -$t2 = SystemTerminal::fromStreams($pipes[1]); -$tUnrelated = SystemTerminal::fromStreams($pipes2[0]); +$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)); +var_dump(isPtyRaw($pipes[0])); +$t1->restoreMode($m); +var_dump(!isPtyRaw($pipes[0])); +echo "restored m\n"; // Test 2: Different Terminal on same device $m1 = $t1->enableRawMode(); -var_dump($t2->restoreMode($m1)); +$t2->restoreMode($m1); +echo "restored m1 via t2 +"; // Test 3: Unrelated terminal rejection (ValueError) $m1 = $t1->enableRawMode(); try { $tUnrelated->restoreMode($m1); - echo "FAIL: unrelated terminal accepted token\n"; + echo "FAIL: unrelated terminal accepted token +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -var_dump($t1->restoreMode($m1)); +$t1->restoreMode($m1); +echo "restored m1 via t1 +"; // Test 4: Token survives Terminal destruction when held externally -$tTemp = SystemTerminal::fromStreams($pipes[0]); +$tTemp = Terminal::fromStreams($pipes[0]); $mTemp = $tTemp->enableRawMode(); 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)); +$t1->restoreMode($mTemp); +echo "restored mTemp via t1 +"; // After restoration, the token is consumed; reusing it throws ValueError try { $t1->restoreMode($mTemp); - echo "FAIL: stale token after restoration accepted\n"; + echo "FAIL: stale token after restoration accepted +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -101,33 +121,48 @@ $m2 = $t2->enableRawMode(); var_dump($m1 instanceof ModeToken); var_dump($m2 instanceof ModeToken); +var_dump(isPtyRaw($pipes[0])); // Out-of-order: restore older token m1 first -var_dump($t1->restoreMode($m1)); +$t1->restoreMode($m1); +// Intermediate lease release: OS mode must remain raw +var_dump(isPtyRaw($pipes[0])); +echo "restored m1 out-of-order\n"; -// Now restore m2 -var_dump($t2->restoreMode($m2)); +// Now restore m2 (final lease) +$t2->restoreMode($m2); +// Final lease release: OS mode must be restored to canonical +var_dump(!isPtyRaw($pipes[0])); +echo "restored m2 out-of-order\n"; // Test 6: In-order restore (m2 then m1) $m1 = $t1->enableRawMode(); $m2 = $t2->enableRawMode(); -var_dump($t2->restoreMode($m2)); -var_dump($t1->restoreMode($m1)); +$t2->restoreMode($m2); +echo "restored m2 in-order\n"; +$t1->restoreMode($m1); +echo "restored m1 in-order\n"; // Test 7: Destroying the final externally-held token restores canonical mode -$tLive = SystemTerminal::fromStreams($pipes[0]); +$tLive = Terminal::fromStreams($pipes[0]); $mHeld = $tLive->enableRawMode(); +var_dump(isPtyRaw($pipes[0])); unset($tLive); // Terminal dropped; $mHeld keeps raw mode active +var_dump(isPtyRaw($pipes[0])); unset($mHeld); // Final token destroyed; ModeToken destructor restores canonical mode +var_dump(!isPtyRaw($pipes[0])); -// Terminal is back in canonical mode; further restore returns false -$tVerify = SystemTerminal::fromStreams($pipes[0]); -var_dump($tVerify->restoreMode()); +// Reusing consumed m1 throws ValueError +try { + $t1->restoreMode($m1); +} catch (ValueError $e) { + echo "Reusing consumed token caught: ", $e->getMessage(), "\n"; +} fwrite($pipes[0], "exit\n"); fwrite($pipes2[0], "exit\n"); -unset($t1, $t2, $tUnrelated, $tVerify); +unset($t1, $t2, $tUnrelated); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } @@ -141,14 +176,22 @@ proc_close($proc2); bool(true) bool(true) bool(true) -ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +restored m +restored m1 via t2 +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal +restored m1 via t1 +restored mTemp via t1 +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\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) bool(true) +restored m1 out-of-order bool(true) +restored m2 out-of-order +restored m2 in-order +restored m1 in-order bool(true) bool(true) bool(true) -bool(false) +Reusing consumed token 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_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt index ed51fb1f4fff..dbfbc85befc7 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\SystemTerminal: enableRawMode, restoreMode, session mode tracking and stale token protection +Io\Terminal\Terminal: enableRawMode, restoreMode, session mode tracking and stale token protection --SKIPIF-- enableRawMode(); -echo "enableRawMode: ", ($token instanceof ModeToken ? "ModeToken" : "false"), "\n"; +echo "enableRawMode: ", ($token instanceof ModeToken ? "ModeToken" : "false"), " +"; // Restore explicitly with token -var_dump($terminal->restoreMode($token)); +$terminal->restoreMode($token); +echo "restored token +"; // Stale consumed token must throw ValueError try { $terminal->restoreMode($token); - echo "FAIL: stale token was accepted\n"; + echo "FAIL: stale token was accepted +"; } 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 when token is unreferenced -$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 = SystemTerminal::fromStreams($pipes[0]); +$terminal3 = Terminal::fromStreams($pipes[0]); $token3 = $terminal3->enableRawMode(); var_dump($token3 instanceof ModeToken); 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)); +$terminal->restoreMode($token3); +echo "restored token3 via terminal +"; // Once restored, reusing the token throws ValueError try { $terminal->restoreMode($token3); - echo "FAIL: consumed token was accepted\n"; + echo "FAIL: consumed token was accepted +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -101,7 +97,7 @@ $proc2 = proc_open( ], $pipes2, ); -$terminalClosed = SystemTerminal::fromStreams($pipes2[0]); +$terminalClosed = Terminal::fromStreams($pipes2[0]); $tokenClosed = $terminalClosed->enableRawMode(); var_dump($tokenClosed instanceof ModeToken); @@ -109,27 +105,28 @@ var_dump($tokenClosed instanceof ModeToken); fclose($pipes2[0]); // Restoration succeeds via the record's owned descriptor -var_dump($terminalClosed->restoreMode($tokenClosed)); +$terminalClosed->restoreMode($tokenClosed); +echo "restored tokenClosed via owned descriptor +"; // Token is now consumed; reusing it throws ValueError try { $terminalClosed->restoreMode($tokenClosed); - echo "FAIL: consumed token was accepted\n"; + echo "FAIL: consumed token was accepted +"; } 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) { if (is_resource($pipe)) fclose($pipe); } proc_close($proc2); -fwrite($pipes[0], "exit\n"); -unset($terminal, $token3, $terminalClosed, $tokenClosed); +fwrite($pipes[0], "exit +"); +unset($terminal, $terminalClosed); foreach ($pipes as $pipe) { if (is_resource($pipe)) fclose($pipe); } @@ -137,15 +134,11 @@ proc_close($proc); ?> --EXPECT-- enableRawMode: ModeToken +restored token +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) -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\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal -bool(true) +restored token3 via terminal +ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) -ValueError: Io\Terminal\SystemTerminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal -bool(false) +restored tokenClosed via owned descriptor +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_raw_mode_failure_injection.phpt b/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt new file mode 100644 index 000000000000..5f1d8ffd52a7 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt @@ -0,0 +1,126 @@ +--TEST-- +Io\Terminal\Terminal: raw mode acquisition and restoration failure rollback and retry +--SKIPIF-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) return false; + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); + return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false; +} + +$proc = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes +); +$term = Terminal::fromStreams($pipes[0]); + +// Test 1: Reapplication failure must not leak lease count +$m1 = $term->enableRawMode(); +var_dump(isPtyRaw($pipes[0])); + +putenv("PHP_TERMINAL_TEST_SEAM=fail_reapply"); +try { + $m2 = $term->enableRawMode(); + echo "FAIL: reapplication succeeded despite failure seam\n"; +} catch (Throwable $e) { + echo "PASS: reapplication rejected: " . $e->getMessage() . "\n"; +} +putenv("PHP_TERMINAL_TEST_SEAM"); + +// Because reapplication failed, lease count was NOT incremented. +// Restoring $m1 must therefore restore canonical mode immediately! +$term->restoreMode($m1); +var_dump(!isPtyRaw($pipes[0])); + +// Test 2: Failed explicit restoration leaves retryable token +$mRetry = $term->enableRawMode(); +var_dump(isPtyRaw($pipes[0])); + +putenv("PHP_TERMINAL_TEST_SEAM=fail_restore_once"); +try { + $term->restoreMode($mRetry); + echo "FAIL: restore succeeded despite failure seam\n"; +} catch (TerminalException $e) { + echo "PASS: restore threw: " . $e->getMessage() . "\n"; +} +putenv("PHP_TERMINAL_TEST_SEAM"); + +// Token must still be active and terminal still in raw mode +var_dump(isPtyRaw($pipes[0])); + +// Retry restoration succeeds and restores mode +$term->restoreMode($mRetry); +var_dump(!isPtyRaw($pipes[0])); + +// Third restore attempt fails because token was consumed +try { + $term->restoreMode($mRetry); + echo "FAIL: consumed token accepted on retry\n"; +} catch (ValueError $e) { + echo "PASS: token consumed on successful retry\n"; +} + +// Test 3: Postcondition failure rolls back initial mode +putenv("PHP_TERMINAL_TEST_SEAM=fail_postcondition"); +try { + $mFail = $term->enableRawMode(); + echo "FAIL: acquisition succeeded despite postcondition failure\n"; +} catch (Throwable $e) { + echo "PASS: postcondition failure caught: " . $e->getMessage() . "\n"; +} +putenv("PHP_TERMINAL_TEST_SEAM"); +// Terminal must NOT be left in raw mode after failed acquisition +var_dump(!isPtyRaw($pipes[0])); + +fwrite($pipes[0], "\n"); +fclose($pipes[0]); +if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]); +if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]); +proc_terminate($proc); +proc_close($proc); + +?> +--EXPECTF-- +bool(true) +PASS: reapplication rejected: %s +bool(true) +bool(true) +PASS: restore threw: Failed to restore terminal mode +bool(true) +bool(true) +PASS: token consumed on successful retry +PASS: postcondition failure caught: %s +bool(true) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt b/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt new file mode 100644 index 000000000000..6fcddde61e88 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt @@ -0,0 +1,114 @@ +--TEST-- +Io\Terminal\Terminal: raw mode token ownership and child process fork isolation +--SKIPIF-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes +); + +$term = Terminal::fromStreams($pipes[0]); + +function isPtyRaw($stream): bool { + $sub = proc_open(['stty', '-a'], [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) return false; + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); + return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false; +} + +// 1. Parent enables raw mode +$token = $term->enableRawMode(); +var_dump($token !== null); + +// 2. Fork child +$pid = pcntl_fork(); +if ($pid === -1) { + die("Failed to fork"); +} + +if ($pid === 0) { + // Child process: + // A. Explicit restore by child must be rejected + try { + $term->restoreMode($token); + echo "CHILD_RESTORE_FAIL: restore succeeded in child\n"; + } catch (\Throwable $e) { + echo "CHILD_RESTORE_PASS: " . $e->getMessage() . "\n"; + } + + // B. Child unsets inherited token and terminates + unset($token); + exit(0); +} + +// Parent process waits for child to exit +pcntl_waitpid($pid, $status); + +// Verify actual OS mode after child exit and before parent restoration +var_dump(isPtyRaw($pipes[0])); + +// 3. Parent verifies its token is still active and can be restored +try { + $term->restoreMode($token); + echo "PARENT_RESTORE_PASS\n"; +} catch (\Throwable $e) { + echo "PARENT_RESTORE_FAIL: " . $e->getMessage() . "\n"; +} + +// Verify actual OS mode after parent restoration +var_dump(!isPtyRaw($pipes[0])); + +// 4. Token cannot be used a second time +try { + $term->restoreMode($token); + echo "REUSE_FAIL\n"; +} catch (\Throwable $e) { + echo "REUSE_PASS\n"; +} + +fwrite($pipes[0], "\n"); +fclose($pipes[0]); +if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]); +if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]); +proc_terminate($proc); +proc_close($proc); + +?> +--EXPECTF-- +bool(true) +CHILD_RESTORE_PASS: %smust be an active terminal mode token belonging to this terminal +bool(true) +PARENT_RESTORE_PASS +bool(true) +REUSE_PASS 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 b73108a0a465..79b1bd0fcf97 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -1,23 +1,39 @@ --TEST-- -Io\Terminal\SystemTerminal: enableRawMode throws TerminalException for non-terminal streams +Io\Terminal\Terminal: no-op raw-mode leases on non-terminal streams --FILE-- enableRawMode(); +var_dump($token instanceof ModeToken); + +// getSize() continues to return null on non-terminal streams +var_dump($terminal->getSize()); + +// Restoring the token succeeds +$terminal->restoreMode($token); +echo "restored non-tty token +"; + +// Re-restoring the consumed token throws ValueError try { - $terminal->enableRawMode(); - echo "FAIL: enableRawMode on non-terminal did not throw\n"; -} catch (Throwable $e) { - echo $e::class, ": ", $e->getMessage(), PHP_EOL; + $terminal->restoreMode($token); + echo "FAIL: re-restoring consumed non-tty token accepted +"; +} catch (ValueError $e) { + echo "Caught: ", $e->getMessage(), PHP_EOL; } -var_dump($terminal->restoreMode()); fclose($fp); ?> --EXPECT-- -Io\Terminal\TerminalException: Failed to enable terminal raw mode -bool(false) +bool(true) +NULL +restored non-tty token +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_raw_mode_output_flags.phpt b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt new file mode 100644 index 000000000000..1cb23c002156 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt @@ -0,0 +1,115 @@ +--TEST-- +Io\Terminal\Terminal: c_oflag is preserved when entering raw mode with OPOST on and off +--SKIPIF-- + ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($sub)) { + return false; + } + $out = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($sub); + return strpos($out, " opost") !== false && strpos($out, " -opost") === false; +} + +function setOpostState($stream, bool $enable): void { + $cmd = $enable ? ['stty', 'opost'] : ['stty', '-opost']; + $sub = proc_open($cmd, [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (is_resource($sub)) { + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($sub); + } +} + +// Case 1: OPOST is initially ON +$proc1 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes1 +); +$term1 = Terminal::fromStreams($pipes1[0]); + +$before1 = getOpostState($pipes1[0]); +$token1 = $term1->enableRawMode(); +$during1 = getOpostState($pipes1[0]); +$term1->restoreMode($token1); +$after1 = getOpostState($pipes1[0]); + +// OPOST was ON before, must stay ON during raw mode and after restore +var_dump($before1 === true); +var_dump($during1 === true); +var_dump($after1 === true); + +fwrite($pipes1[0], "\n"); +fclose($pipes1[0]); +if (isset($pipes1[1]) && is_resource($pipes1[1])) fclose($pipes1[1]); +if (isset($pipes1[2]) && is_resource($pipes1[2])) fclose($pipes1[2]); +proc_terminate($proc1); +proc_close($proc1); + +// Case 2: OPOST is initially OFF +$proc2 = proc_open( + [PHP_BINARY, '-r', 'fgets(STDIN);'], + [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']], + $pipes2 +); +$term2 = Terminal::fromStreams($pipes2[0]); + +// Turn OPOST OFF explicitly before acquiring raw mode +setOpostState($pipes2[0], false); + +$before2 = getOpostState($pipes2[0]); +$token2 = $term2->enableRawMode(); +$during2 = getOpostState($pipes2[0]); +$term2->restoreMode($token2); +$after2 = getOpostState($pipes2[0]); + +// OPOST was OFF before, must NOT be forced ON during raw mode, and remain OFF after restore +var_dump($before2 === false); +var_dump($during2 === false); +var_dump($after2 === false); + +fwrite($pipes2[0], "\n"); +fclose($pipes2[0]); +if (isset($pipes2[1]) && is_resource($pipes2[1])) fclose($pipes2[1]); +if (isset($pipes2[2]) && is_resource($pipes2[2])) fclose($pipes2[2]); +proc_terminate($proc2); +proc_close($proc2); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +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 84f5933e45e9..abf3877c59e7 100644 --- a/ext/standard/tests/terminal/terminal_read_key.phpt +++ b/ext/standard/tests/terminal/terminal_read_key.phpt @@ -1,40 +1,41 @@ --TEST-- -Io\Terminal\SystemTerminal: readKey with Time\Duration timeouts and parameter validation +Io\Terminal\Terminal: readKey with Time\Duration timeouts and parameter validation --FILE-- readKey(Duration::fromSeconds(1)->negate()); - echo "FAIL: accepted negative timeout\n"; + echo "FAIL: accepted negative timeout +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } try { $t->readKey(Duration::fromSeconds(0), Duration::fromSeconds(1)->negate()); - echo "FAIL: accepted negative sequence timeout\n"; + echo "FAIL: accepted negative sequence timeout +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Non-terminal stream throws TerminalException +// Non-terminal stream reads keys without throwing $fp = fopen('php://temp', 'r+'); -$nonTty = SystemTerminal::fromStreams($fp); -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; -} +fwrite($fp, "x"); +rewind($fp); +$nonTty = Terminal::fromStreams($fp); +$key = $nonTty->readKey(Duration::fromSeconds(0)); +var_dump($key === "x"); +fclose($fp); ?> --EXPECT-- -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 +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(true) diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt index ddea9474ae05..7530986edbf8 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\SystemTerminal: readKey null 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 null immediately @@ -67,36 +67,38 @@ 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"; + echo "FAIL: negative timeout accepted +"; } 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"; + echo "FAIL: negative sequence timeout accepted +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } -// Contract 4: Non-terminal input stream throws TerminalException +// Contract 4: Non-terminal input stream reads keys normally $fp = fopen('php://temp', 'r+'); -$nonTty = SystemTerminal::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; -} +fwrite($fp, "z"); +rewind($fp); +$nonTty = Terminal::fromStreams($fp); +$keyNonTty = $nonTty->readKey(Duration::fromSeconds(0)); +var_dump($keyNonTty === "z"); fclose($fp); -// Contract 5: EOF / stream termination throws TerminalException -fwrite($pipes[0], "exit\n"); +// Contract 5: EOF / stream termination throws TerminalException on real TTY +fwrite($pipes[0], "exit +"); usleep(50000); // give child time to exit try { $terminal->readKey(Duration::fromSeconds(1)); - echo "FAIL: readKey on closed/EOF terminal did not throw\n"; + echo "FAIL: readKey on closed/EOF terminal did not throw +"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -112,7 +114,7 @@ bool(true) bool(true) bool(true) bool(true) -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 +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(true) 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 5bb89bed0cf2..67f069947a4e 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(); +$terminal1 = Terminal::fromStreams($pipes1[0]); +$token1 = $terminal1->enableRawMode(); fwrite($pipes1[0], "START\n"); $r1 = fgets($pipes1[2]); @@ -122,8 +122,8 @@ $proc2 = proc_open( ); $r0 = fgets($pipes2[2]); -$terminal2 = SystemTerminal::fromStreams($pipes2[0]); -$terminal2->enableRawMode(); +$terminal2 = Terminal::fromStreams($pipes2[0]); +$token2 = $terminal2->enableRawMode(); fwrite($pipes2[0], "START\n"); $r1 = fgets($pipes2[2]); diff --git a/ext/standard/tests/terminal/terminal_read_key_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt index 31c8ecd783a9..1967a7893e73 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\SystemTerminal: readKey preserves unsupported CSI sequences +Io\Terminal\Terminal: readKey preserves unsupported CSI sequences and decodes Insert --SKIPIF-- enableRawMode(); +$terminal = Terminal::fromStreams($pipes[0]); +$token = $terminal->enableRawMode(); $timeout = Duration::fromSeconds(1); foreach (range(1, 4) as $_) { @@ -81,6 +81,6 @@ proc_close($proc); ?> --EXPECT-- 1b5b313b3541 -1b5b327e +Insert F1 Up diff --git a/ext/standard/tests/terminal/terminal_read_key_timeout.phpt b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt index 9ad908655854..83fcbc99b7f0 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(); +$terminal = Terminal::fromStreams($pipes[0]); +$token = $terminal->enableRawMode(); // Signal child to write lone escape fwrite($pipes[0], "GO\n"); 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 3382f116ab87..94c6b36a162e 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(); +$terminal1 = Terminal::fromStreams($pipes1[0]); +$token1 = $terminal1->enableRawMode(); fwrite($pipes1[0], "START\n"); $r1 = fgets($pipes1[2]); @@ -117,8 +117,8 @@ $proc2 = proc_open( ); $r0 = fgets($pipes2[2]); -$terminal2 = SystemTerminal::fromStreams($pipes2[0]); -$terminal2->enableRawMode(); +$terminal2 = Terminal::fromStreams($pipes2[0]); +$token2 = $terminal2->enableRawMode(); fwrite($pipes2[0], "START\n"); $r1 = fgets($pipes2[2]); @@ -173,20 +173,16 @@ $proc3 = proc_open( ); $r0 = fgets($pipes3[2]); -$terminal3 = SystemTerminal::fromStreams($pipes3[0]); -$terminal3->enableRawMode(); +$terminal3 = Terminal::fromStreams($pipes3[0]); +$token3 = $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 (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; 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 489b43bd4cee..e502868ede63 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]; + $terminal = Terminal::fromStreams($pipes[0]); + $token = $terminal->enableRawMode(); + return [$proc, $pipes, $terminal, $token]; } // Case 1: Complete queued 3-byte UTF-8 (€) with Duration::fromSeconds(0) @@ -63,7 +63,7 @@ fwrite(STDERR, "READY\n"); fflush(STDERR); fgets(STDIN); '; -[$proc1, $pipes1, $terminal1] = spawn_pty_child($code1); +[$proc1, $pipes1, $terminal1, $token1] = spawn_pty_child($code1); fwrite($pipes1[0], "START\n"); $r1 = fgets($pipes1[2]); @@ -71,7 +71,7 @@ $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(); +$terminal1->restoreMode($token1); unset($terminal1); foreach ($pipes1 as $pipe) { if (is_resource($pipe)) fclose($pipe); @@ -90,7 +90,7 @@ fwrite(STDERR, "READY\n"); fflush(STDERR); fgets(STDIN); '; -[$proc2, $pipes2, $terminal2] = spawn_pty_child($code2); +[$proc2, $pipes2, $terminal2, $token2] = spawn_pty_child($code2); fwrite($pipes2[0], "START\n"); $r2 = fgets($pipes2[2]); @@ -98,7 +98,7 @@ $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(); +$terminal2->restoreMode($token2); unset($terminal2); foreach ($pipes2 as $pipe) { if (is_resource($pipe)) fclose($pipe); @@ -123,7 +123,7 @@ fwrite(STDERR, "READY2\n"); fflush(STDERR); fgets(STDIN); '; -[$proc3, $pipes3, $terminal3] = spawn_pty_child($code3); +[$proc3, $pipes3, $terminal3, $token3] = spawn_pty_child($code3); fwrite($pipes3[0], "START\n"); $r3_1 = fgets($pipes3[2]); @@ -137,7 +137,7 @@ $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(); +$terminal3->restoreMode($token3); unset($terminal3); foreach ($pipes3 as $pipe) { if (is_resource($pipe)) fclose($pipe); @@ -163,7 +163,7 @@ fwrite(STDERR, "READY2\n"); fflush(STDERR); fgets(STDIN); '; -[$proc4, $pipes4, $terminal4] = spawn_pty_child($code4); +[$proc4, $pipes4, $terminal4, $token4] = spawn_pty_child($code4); fwrite($pipes4[0], "START\n"); $r4_1 = fgets($pipes4[2]); @@ -173,7 +173,7 @@ echo "Case 4: ", var_export($k4 === "€", true), " hex: ", bin2hex($k4), PHP_EO $r4_2 = fgets($pipes4[2]); fwrite($pipes4[0], "done\n"); -$terminal4->restoreMode(); +$terminal4->restoreMode($token4); unset($terminal4); foreach ($pipes4 as $pipe) { if (is_resource($pipe)) fclose($pipe); @@ -199,7 +199,7 @@ fwrite(STDERR, "READY2\n"); fflush(STDERR); fgets(STDIN); '; -[$proc5, $pipes5, $terminal5] = spawn_pty_child($code5); +[$proc5, $pipes5, $terminal5, $token5] = spawn_pty_child($code5); fwrite($pipes5[0], "START\n"); $r5_1 = fgets($pipes5[2]); @@ -209,7 +209,7 @@ echo "Case 5: ", var_export($k5 === "€", true), " hex: ", bin2hex($k5), PHP_EO $r5_2 = fgets($pipes5[2]); fwrite($pipes5[0], "done\n"); -$terminal5->restoreMode(); +$terminal5->restoreMode($token5); unset($terminal5); foreach ($pipes5 as $pipe) { if (is_resource($pipe)) fclose($pipe); diff --git a/ext/standard/tests/terminal/terminal_read_line.phpt b/ext/standard/tests/terminal/terminal_read_line.phpt index 07ae421d0f32..ee2d9fb2c51e 100644 --- a/ext/standard/tests/terminal/terminal_read_line.phpt +++ b/ext/standard/tests/terminal/terminal_read_line.phpt @@ -1,20 +1,20 @@ --TEST-- -Io\Terminal\SystemTerminal: readLine line ending, whitespace, buffering, and EOF stream contracts +Io\Terminal\Terminal: readLine line ending, whitespace, buffering, and EOF stream contracts --FILE-- isFinal()); $reflection = new ReflectionMethod(Terminal::class, 'readLine'); var_dump($reflection->getNumberOfParameters()); var_dump((string) $reflection->getReturnType()); // 1. Argument validation: readLine takes 0 parameters $fp = fopen('php://temp', 'r+'); -$terminal = SystemTerminal::fromStreams($fp); +$terminal = Terminal::fromStreams($fp); try { $terminal->readLine('extra'); echo "FAIL: readLine accepted argument\n"; @@ -99,7 +99,7 @@ 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 +// Terminal::readLine must consume from php_stream buffer, not bypass it var_dump($terminal->readLine()); var_dump($terminal->readLine()); var_dump($terminal->readLine()); // EOF @@ -110,7 +110,7 @@ fclose($fp); $mem = fopen('php://memory', 'r+'); fwrite($mem, "memory line\n"); rewind($mem); -$tMem = SystemTerminal::fromStreams($mem); +$tMem = Terminal::fromStreams($mem); var_dump($tMem->readLine()); fclose($mem); @@ -118,7 +118,7 @@ fclose($mem); $fp2 = fopen('php://temp', 'r+'); fwrite($fp2, "\r\n\r\nline with crlf\r\n\r\n"); rewind($fp2); -$t2 = SystemTerminal::fromStreams($fp2); +$t2 = Terminal::fromStreams($fp2); var_dump($t2->readLine()); var_dump($t2->readLine()); var_dump($t2->readLine()); @@ -135,13 +135,9 @@ 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()); +// 15. Mode restoration & terminal state usability after raw mode on non-terminal stream +$token2 = $t2->enableRawMode(); +$t2->restoreMode($token2); ftruncate($fp2, 0); rewind($fp2); fwrite($fp2, "post restoration line\r\n"); @@ -154,7 +150,7 @@ fclose($fp2); bool(true) int(0) string(7) "?string" -ArgumentCountError: Io\Terminal\SystemTerminal::readLine() expects exactly 0 arguments, 1 given +ArgumentCountError: Io\Terminal\Terminal::readLine() expects exactly 0 arguments, 1 given NULL string(5) "hello" NULL @@ -190,6 +186,4 @@ 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_lease.phpt b/ext/standard/tests/terminal/terminal_read_line_lease.phpt index 4cceffa17481..c4167f8729ab 100644 --- a/ext/standard/tests/terminal/terminal_read_line_lease.phpt +++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\SystemTerminal: readLine raw-mode lease conflict, cross-instance validation, and PTY line contracts +Io\Terminal\Terminal: readLine raw-mode lease conflict, cross-instance validation, and PTY line contracts --SKIPIF-- enableRawMode(); @@ -99,8 +99,8 @@ try { } // 3. Cross-instance restoreMode($token) succeeds and restores mode across wrappers -$restored = $t2->restoreMode($token); -var_dump($restored); +$t2->restoreMode($token); +echo "restored via t2\n"; // 4. readLine on original wrapper works normally after cross-wrapper restoration fwrite($pipes[0], "GO\n"); @@ -112,10 +112,15 @@ fwrite($pipes[0], "GO\n"); fgets($pipes[2]); // Wait for READY2 var_dump($t2->readLine()); -// 6. No-argument restoreMode() on a wrapper with a stale active token returns false +// 6. Restoring an already consumed token throws ValueError $tokenStale = $t1->enableRawMode(); $t2->restoreMode($tokenStale); -var_dump($t1->restoreMode() === false); +try { + $t1->restoreMode($tokenStale); + echo "FAIL: stale token was accepted\n"; +} catch (ValueError $e) { + echo "Stale token caught: ", $e->getMessage(), PHP_EOL; +} // 7. Fragmented UTF-8 prepending: pending UTF-8 bytes from readKey are prepended to readLine $token2 = $t1->enableRawMode(); @@ -148,10 +153,10 @@ 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) +restored via t2 string(14) "canonical line" string(11) "second line" -bool(true) +Stale token caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal bool(true) string(7) "éclair" NULL diff --git a/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt index decc1741b975..9b44c3101078 100644 --- a/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt +++ b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\SystemTerminal: readLine nonblocking TTY EAGAIN waiting, partial lines, and EOF contracts +Io\Terminal\Terminal: readLine nonblocking TTY EAGAIN waiting, partial lines, and EOF contracts --SKIPIF-- readLine()); fclose($pipes1[0]); @@ -88,11 +86,9 @@ $proc2 = proc_open( fgets($pipes2[2]); // Wait for STARTED stream_set_blocking($pipes2[1], false); -$terminal2 = SystemTerminal::fromStreams($pipes2[1]); +$terminal2 = Terminal::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]); @@ -115,7 +111,7 @@ 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]); +$terminal3 = Terminal::fromStreams($pipes3[1]); var_dump($terminal3->readLine()); @@ -142,7 +138,7 @@ $proc4 = proc_open( fgets($pipes4[2]); // Wait for STARTED stream_set_blocking($pipes4[1], false); -$terminal4 = SystemTerminal::fromStreams($pipes4[1]); +$terminal4 = Terminal::fromStreams($pipes4[1]); fwrite($pipes4[0], "GO\n"); usleep(30000); // Allow child to write and exit diff --git a/ext/standard/tests/terminal/terminal_read_line_windows.phpt b/ext/standard/tests/terminal/terminal_read_line_windows.phpt index d847fcc73a63..baf63b40ccec 100644 --- a/ext/standard/tests/terminal/terminal_read_line_windows.phpt +++ b/ext/standard/tests/terminal/terminal_read_line_windows.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\SystemTerminal: Windows console and stream readLine contracts +Io\Terminal\Terminal: Windows console and stream readLine contracts --SKIPIF-- 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()); +// 3. No-op raw mode lease on non-terminal stream +$token = $terminal->enableRawMode(); +var_dump($token instanceof ModeToken); +$terminal->restoreMode($token); +echo "restored non-tty token\n"; ftruncate($fp, 0); rewind($fp); @@ -62,6 +57,6 @@ NULL string(18) "🐘 PHP 🦀 Rust" string(6) "東京" NULL -enableRawMode exception: Failed to enable terminal raw mode -bool(false) +bool(true) +restored non-tty token string(23) "usable after mode check" diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt index 0cf9efbbf9e6..14b28590608a 100644 --- a/ext/standard/tests/terminal/terminal_read_secret.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret.phpt @@ -1,21 +1,21 @@ --TEST-- -Io\Terminal\SystemTerminal: readSecret parameter validation and non-tty failure handling +Io\Terminal\Terminal: readSecret parameter validation and empty non-tty handling --FILE-- readSecret(); - echo "FAIL: readSecret succeeded on non-tty\n"; + echo "FAIL: readSecret succeeded on empty non-tty\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -44,10 +44,12 @@ try { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } +fclose($fp); + ?> --EXPECTF-- bool(true) Io\Terminal\TerminalException: Unable to read secret from terminal -TypeError: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must be of type ?Time\Duration, string given -ArgumentCountError: Io\Terminal\SystemTerminal::readSecret() expects at most 1 argument, 2 given -ValueError: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must not be negative +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 f794cbdee1c3..26ad3f7ade50 100644 --- a/ext/standard/tests/terminal/terminal_read_secret_control.phpt +++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt @@ -32,7 +32,7 @@ proc_close($proc); --FILE-- readSecret(); @@ -63,8 +63,8 @@ foreach ($pipes1 as $pipe) { } 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);'; +// 2. Escape followed by Ctrl+C (\x1b\x03) cancels secret input immediately +$code2 = 'fwrite(STDOUT, "\x1b\x03\r"); fflush(STDOUT); fgets(STDIN);'; $proc2 = proc_open( [PHP_BINARY, '-r', $code2], [ @@ -75,9 +75,14 @@ $proc2 = proc_open( $pipes2, ); -$terminal2 = SystemTerminal::fromStreams($pipes2[0]); -$secret2 = $terminal2->readSecret(); -echo "Fragmented UTF-8: ", bin2hex($secret2), "\n"; +$terminal2 = Terminal::fromStreams($pipes2[0]); + +try { + $secret = $terminal2->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} fwrite($pipes2[0], "done\n"); unset($terminal2); @@ -86,8 +91,8 @@ foreach ($pipes2 as $pipe) { } proc_close($proc2); -// 3. Escape followed immediately by PTY disconnect -$code3 = 'fwrite(STDOUT, "\x1b"); fflush(STDOUT); usleep(10000); exit(0);'; +// 3. CSI sequence containing Ctrl+C aborts +$code3 = 'fwrite(STDOUT, "\x1b[\x03A\r"); fflush(STDOUT); fgets(STDIN);'; $proc3 = proc_open( [PHP_BINARY, '-r', $code3], [ @@ -98,22 +103,24 @@ $proc3 = proc_open( $pipes3, ); -$terminal3 = SystemTerminal::fromStreams($pipes3[0]); +$terminal3 = Terminal::fromStreams($pipes3[0]); + try { - $terminal3->readSecret(); - echo "FAIL: expected exception on disconnect\n"; + $secret = $terminal3->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { - echo "Disconnect: caught ", $e::class, ": ", $e->getMessage(), "\n"; + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } +fwrite($pipes3[0], "done\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);'; +// 4. SS3 sequence containing Ctrl+C aborts +$code4 = 'fwrite(STDOUT, "\x1bO\x03\r"); fflush(STDOUT); fgets(STDIN);'; $proc4 = proc_open( [PHP_BINARY, '-r', $code4], [ @@ -124,12 +131,13 @@ $proc4 = proc_open( $pipes4, ); -$terminal4 = SystemTerminal::fromStreams($pipes4[0]); +$terminal4 = Terminal::fromStreams($pipes4[0]); + try { - $terminal4->readSecret(); - echo "FAIL: expected abort on escape+Ctrl+C\n"; + $secret = $terminal4->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { - echo "Escape+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } fwrite($pipes4[0], "done\n"); @@ -139,7 +147,7 @@ foreach ($pipes4 as $pipe) { } proc_close($proc4); -// 5. Escape followed immediately by Enter (\r) +// 5. Escape followed by enter (\x1b\r) aborts $code5 = 'fwrite(STDOUT, "\x1b\r"); fflush(STDOUT); fgets(STDIN);'; $proc5 = proc_open( [PHP_BINARY, '-r', $code5], @@ -151,12 +159,13 @@ $proc5 = proc_open( $pipes5, ); -$terminal5 = SystemTerminal::fromStreams($pipes5[0]); +$terminal5 = Terminal::fromStreams($pipes5[0]); + try { - $terminal5->readSecret(); - echo "FAIL: expected abort on escape+Enter\n"; + $secret = $terminal5->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { - echo "Escape+Enter: caught ", $e::class, ": ", $e->getMessage(), "\n"; + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } fwrite($pipes5[0], "done\n"); @@ -166,8 +175,8 @@ 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);'; +// 6. Escape followed by escape (\x1b\x1b) aborts +$code6 = 'fwrite(STDOUT, "\x1b\x1b\r"); fflush(STDOUT); fgets(STDIN);'; $proc6 = proc_open( [PHP_BINARY, '-r', $code6], [ @@ -177,13 +186,16 @@ $proc6 = proc_open( ], $pipes6, ); -$terminal6 = SystemTerminal::fromStreams($pipes6[0]); + +$terminal6 = Terminal::fromStreams($pipes6[0]); + try { - $terminal6->readSecret(); - echo "FAIL: expected abort on CSI+Ctrl+C\n"; + $secret = $terminal6->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { - echo "CSI+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } + fwrite($pipes6[0], "done\n"); unset($terminal6); foreach ($pipes6 as $pipe) { @@ -191,8 +203,8 @@ foreach ($pipes6 as $pipe) { } proc_close($proc6); -// 7. CSI followed by Ctrl+D (\x1b[\x04) cancels -$code7 = 'fwrite(STDOUT, "\x1b[\x04"); fflush(STDOUT); fgets(STDIN);'; +// 7. Escape followed by Ctrl+D (\x1b\x04) aborts +$code7 = 'fwrite(STDOUT, "\x1b\x04\r"); fflush(STDOUT); fgets(STDIN);'; $proc7 = proc_open( [PHP_BINARY, '-r', $code7], [ @@ -202,13 +214,16 @@ $proc7 = proc_open( ], $pipes7, ); -$terminal7 = SystemTerminal::fromStreams($pipes7[0]); + +$terminal7 = Terminal::fromStreams($pipes7[0]); + try { - $terminal7->readSecret(); - echo "FAIL: expected abort on CSI+Ctrl+D\n"; + $secret = $terminal7->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { - echo "CSI+Ctrl+D: caught ", $e::class, ": ", $e->getMessage(), "\n"; + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } + fwrite($pipes7[0], "done\n"); unset($terminal7); foreach ($pipes7 as $pipe) { @@ -216,8 +231,8 @@ foreach ($pipes7 as $pipe) { } proc_close($proc7); -// 8. SS3 followed by Ctrl+C (\x1bO\x03) cancels -$code8 = 'fwrite(STDOUT, "\x1bO\x03"); fflush(STDOUT); fgets(STDIN);'; +// 8. CSI sequence with cancellation byte \x04 aborts +$code8 = 'fwrite(STDOUT, "\x1b[\x04\r"); fflush(STDOUT); fgets(STDIN);'; $proc8 = proc_open( [PHP_BINARY, '-r', $code8], [ @@ -227,13 +242,16 @@ $proc8 = proc_open( ], $pipes8, ); -$terminal8 = SystemTerminal::fromStreams($pipes8[0]); + +$terminal8 = Terminal::fromStreams($pipes8[0]); + try { - $terminal8->readSecret(); - echo "FAIL: expected abort on SS3+Ctrl+C\n"; + $secret = $terminal8->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; } catch (TerminalException $e) { - echo "SS3+Ctrl+C: caught ", $e::class, ": ", $e->getMessage(), "\n"; + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; } + fwrite($pipes8[0], "done\n"); unset($terminal8); foreach ($pipes8 as $pipe) { @@ -241,8 +259,8 @@ foreach ($pipes8 as $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);'; +// 9. CSI sequence with cancellation byte \x1b aborts +$code9 = 'fwrite(STDOUT, "\x1b[\x1b\r"); fflush(STDOUT); fgets(STDIN);'; $proc9 = proc_open( [PHP_BINARY, '-r', $code9], [ @@ -252,9 +270,16 @@ $proc9 = proc_open( ], $pipes9, ); -$terminal9 = SystemTerminal::fromStreams($pipes9[0]); -$secret9 = $terminal9->readSecret(); -echo "Valid CSI Up ignored: ", bin2hex($secret9), "\n"; + +$terminal9 = Terminal::fromStreams($pipes9[0]); + +try { + $secret = $terminal9->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + fwrite($pipes9[0], "done\n"); unset($terminal9); foreach ($pipes9 as $pipe) { @@ -262,8 +287,8 @@ foreach ($pipes9 as $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);'; +// 10. CSI sequence with cancellation byte \r aborts +$code10 = 'fwrite(STDOUT, "\x1b[\r"); fflush(STDOUT); fgets(STDIN);'; $proc10 = proc_open( [PHP_BINARY, '-r', $code10], [ @@ -273,9 +298,16 @@ $proc10 = proc_open( ], $pipes10, ); -$terminal10 = SystemTerminal::fromStreams($pipes10[0]); -$secret10 = $terminal10->readSecret(); -echo "Valid SS3 F1 ignored: ", bin2hex($secret10), "\n"; + +$terminal10 = Terminal::fromStreams($pipes10[0]); + +try { + $secret = $terminal10->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + fwrite($pipes10[0], "done\n"); unset($terminal10); foreach ($pipes10 as $pipe) { @@ -283,8 +315,8 @@ foreach ($pipes10 as $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);'; +// 11. SS3 sequence with cancellation byte \x04 aborts +$code11 = 'fwrite(STDOUT, "\x1bO\x04\r"); fflush(STDOUT); fgets(STDIN);'; $proc11 = proc_open( [PHP_BINARY, '-r', $code11], [ @@ -294,9 +326,16 @@ $proc11 = proc_open( ], $pipes11, ); -$terminal11 = SystemTerminal::fromStreams($pipes11[0]); -$secret11 = $terminal11->readSecret(); -echo "Internal timeout continue: ", bin2hex($secret11), "\n"; + +$terminal11 = Terminal::fromStreams($pipes11[0]); + +try { + $secret = $terminal11->readSecret(); + echo "FAIL: secret was read: ", bin2hex($secret), "\n"; +} catch (TerminalException $e) { + echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n"; +} + fwrite($pipes11[0], "done\n"); unset($terminal11); foreach ($pipes11 as $pipe) { @@ -307,13 +346,13 @@ proc_close($proc11); ?> --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 -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 +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal +SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt index 700b2de4bdbc..b0fc296ecf04 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\SystemTerminal: readSecret timeout, empty submission, and cancellation contracts +Io\Terminal\Terminal: readSecret timeout, empty submission, and cancellation contracts --SKIPIF-- readSecret(Duration::fromMilliseconds(25)); $elapsed_ms = (hrtime(true) - $start) / 1e6; @@ -83,8 +83,8 @@ $proc2 = proc_open( $pipes2, ); $r0 = fgets($pipes2[2]); -$terminal2 = SystemTerminal::fromStreams($pipes2[0]); -$terminal2->enableRawMode(); +$terminal2 = Terminal::fromStreams($pipes2[0]); +$token2 = $terminal2->enableRawMode(); fwrite($pipes2[0], "START\n"); $r1 = fgets($pipes2[2]); @@ -122,8 +122,8 @@ $proc3 = proc_open( $pipes3, ); $r0 = fgets($pipes3[2]); -$terminal3 = SystemTerminal::fromStreams($pipes3[0]); -$terminal3->enableRawMode(); +$terminal3 = Terminal::fromStreams($pipes3[0]); +$token3 = $terminal3->enableRawMode(); fwrite($pipes3[0], "START\n"); $r1 = fgets($pipes3[2]); @@ -161,8 +161,8 @@ $proc4 = proc_open( $pipes4, ); $r0 = fgets($pipes4[2]); -$terminal4 = SystemTerminal::fromStreams($pipes4[0]); -$terminal4->enableRawMode(); +$terminal4 = Terminal::fromStreams($pipes4[0]); +$token4 = $terminal4->enableRawMode(); fwrite($pipes4[0], "START\n"); $r1 = fgets($pipes4[2]); @@ -191,7 +191,7 @@ $proc5 = proc_open( ], $pipes5, ); -$terminal5 = SystemTerminal::fromStreams($pipes5[0]); +$terminal5 = Terminal::fromStreams($pipes5[0]); try { $terminal5->readSecret(Duration::fromSeconds(1)->negate()); echo "FAIL: expected ValueError on negative duration\n"; @@ -228,8 +228,8 @@ $proc6 = proc_open( $pipes6, ); $r0 = fgets($pipes6[2]); -$terminal6 = SystemTerminal::fromStreams($pipes6[0]); -$terminal6->enableRawMode(); +$terminal6 = Terminal::fromStreams($pipes6[0]); +$token6 = $terminal6->enableRawMode(); fwrite($pipes6[0], "START\n"); $r1 = fgets($pipes6[2]); @@ -266,8 +266,8 @@ $proc7 = proc_open( $pipes7, ); $r0 = fgets($pipes7[2]); -$terminal7 = SystemTerminal::fromStreams($pipes7[0]); -$terminal7->enableRawMode(); +$terminal7 = Terminal::fromStreams($pipes7[0]); +$token7 = $terminal7->enableRawMode(); fwrite($pipes7[0], "START\n"); $r1 = fgets($pipes7[2]); @@ -293,6 +293,6 @@ bool(true) bool(true) bool(true) Cancellation caught: Unable to read secret from terminal -Negative timeout caught: Io\Terminal\SystemTerminal::readSecret(): Argument #1 ($timeout) must not be negative +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 diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt index e53b22fc8192..f0fb31f81cb8 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_secret_crlf_handoff.phpt b/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt new file mode 100644 index 000000000000..4fdab3f5ee64 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt @@ -0,0 +1,189 @@ +--TEST-- +Io\Terminal\Terminal: CRLF consumption across secret boundaries and chunk splits +--SKIPIF-- + +--FILE-- +readSecret(); +$s2 = $term1->readSecret(); +$k1 = $term1->readKey(); + +var_dump($s1 === "secretA"); +var_dump($s2 === "secretB"); +var_dump($k1 === "K"); +fclose($fp); + +// Part 2: Split CRLF across write chunks +$pair = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +$readStream = $pair[0]; +$writeStream = $pair[1]; +$term2 = Terminal::fromStreams($readStream); + +// First write terminates with \r only +fwrite($writeStream, "alpha\r"); +$sAlpha = $term2->readSecret(); +var_dump($sAlpha === "alpha"); + +// Second write begins with the split \n, then beta\r\n, then trailing key +fwrite($writeStream, "\nbeta\r\nQ"); +$sBeta = $term2->readSecret(); +var_dump($sBeta === "beta"); + +$kQ = $term2->readKey(Duration::fromMilliseconds(100)); +var_dump($kQ === "Q"); + +fclose($readStream); +fclose($writeStream); + +// Part 3: Split CRLF followed by readLine() +$pair3 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +$readStream3 = $pair3[0]; +$writeStream3 = $pair3[1]; +$term3 = Terminal::fromStreams($readStream3); + +fwrite($writeStream3, "password\r"); +$sPass = $term3->readSecret(); +var_dump($sPass === "password"); + +// Next chunk arrives with split \n, then followed by line content and newline +fwrite($writeStream3, "\nmy input line\n"); +$line3 = $term3->readLine(); +var_dump($line3 === "my input line"); + +// Part 4: Chunk size 1, CR then later "next\n" +$p4 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p4[0], 1); +$t4 = Terminal::fromStreams($p4[0]); +fwrite($p4[1], "password\r"); +var_dump($t4->readSecret() === "password"); +fwrite($p4[1], "next\n"); +var_dump($t4->readLine() === "next"); +fclose($p4[0]); +fclose($p4[1]); + +// Part 5: Chunk size 1, CR then later "\nnext\n" +$p5 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p5[0], 1); +$t5 = Terminal::fromStreams($p5[0]); +fwrite($p5[1], "password\r"); +var_dump($t5->readSecret() === "password"); +fwrite($p5[1], "\nnext\n"); +var_dump($t5->readLine() === "next"); +fclose($p5[0]); +fclose($p5[1]); + +// Part 6: Chunk size 1, CR with next line already queued in same write +$p6 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p6[0], 1); +$t6 = Terminal::fromStreams($p6[0]); +fwrite($p6[1], "password\rnext\n"); +var_dump($t6->readSecret() === "password"); +var_dump($t6->readLine() === "next"); +fclose($p6[0]); +fclose($p6[1]); + +// Part 7: Chunk size 1, read "password\r", then supply "\r\nnext\n" -> readLine() returns "" and "next" +$p7 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p7[0], 1); +$t7 = Terminal::fromStreams($p7[0]); +fwrite($p7[1], "password\r"); +var_dump($t7->readSecret() === "password"); +fwrite($p7[1], "\r\nnext\n"); +var_dump($t7->readLine() === ""); +var_dump($t7->readLine() === "next"); +fclose($p7[0]); +fclose($p7[1]); + +// Part 8: A data CR before CRLF must survive split-prefix normalization. +$p8 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p8[0], 1); +$t8 = Terminal::fromStreams($p8[0]); +fwrite($p8[1], "password\r"); +var_dump($t8->readSecret() === "password"); +fwrite($p8[1], "\r\r\nnext\n"); +var_dump($t8->readLine() === "\r"); +var_dump($t8->readLine() === "next"); +fclose($p8[0]); +fclose($p8[1]); + +// Part 9: A bare saved CR at EOF remains unterminated line data. +$p9 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +stream_set_chunk_size($p9[0], 1); +$t9 = Terminal::fromStreams($p9[0]); +fwrite($p9[1], "password\r"); +var_dump($t9->readSecret() === "password"); +fwrite($p9[1], "\r"); +fclose($p9[1]); +var_dump($t9->readLine() === "\r"); +var_dump($t9->readLine() === null); +fclose($p9[0]); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt index 6b857c8a5d06..76fb73d038b4 100644 --- a/ext/standard/tests/terminal/terminal_size.phpt +++ b/ext/standard/tests/terminal/terminal_size.phpt @@ -1,15 +1,15 @@ --TEST-- -Io\Terminal\SystemTerminal: getSize returns null 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 --FILE-- getSize()); diff --git a/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt b/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt new file mode 100644 index 000000000000..e7863379d966 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt @@ -0,0 +1,119 @@ +--TEST-- +Io\Terminal\Terminal: stream readSecret controls, cancellation, navigation, and UTF-8 backspace +--SKIPIF-- + +--FILE-- +readSecret(); + echo "FAIL: Ctrl+C returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+C cancelled\n"; +} + +// 2. Ctrl+D followed by newline: must cancel, not return secret with 0x04 +try { + $t = makeStreamTerminal("\x04\n"); + $sec = $t->readSecret(); + echo "FAIL: Ctrl+D returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Ctrl+D cancelled\n"; +} + +// 3. Escape followed by newline: must cancel, not return secret with 0x1B +try { + $t = makeStreamTerminal("\x1b\n"); + $sec = $t->readSecret(); + echo "FAIL: Escape returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Escape cancelled\n"; +} + +// 4. Navigation sequence \x1b[A followed by text must ignore navigation and return text +$t = makeStreamTerminal("\x1b[Aabc\n"); +$sec = $t->readSecret(); +var_dump($sec === "abc"); + +// 5. UTF-8 backspace with chr(8) removes 3-byte Euro symbol +$t = makeStreamTerminal("€" . chr(8) . "b\n"); +$sec = $t->readSecret(); +var_dump($sec === "b"); + +// 6. ASCII backspace with 0x7F removes preceding byte +$t = makeStreamTerminal("ab\x7fc\n"); +$sec = $t->readSecret(); +var_dump($sec === "ac"); + +// 7. SS3 F1 followed by text must ignore F1 and return text (no 'P' corruption) +$t = makeStreamTerminal("\x1bOPpass\n"); +$sec = $t->readSecret(); +var_dump($sec === "pass"); + +// 8. CSI control cancellation: Ctrl+C inside CSI sequence must cancel +try { + $t = makeStreamTerminal("\x1b[\x03pass\n"); + $sec = $t->readSecret(); + echo "FAIL: CSI Ctrl+C returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: CSI Ctrl+C cancelled\n"; +} + +// 9. SS3 control cancellation: Ctrl+C inside SS3 sequence must cancel +try { + $t = makeStreamTerminal("\x1bO\x03pass\n"); + $sec = $t->readSecret(); + echo "FAIL: SS3 Ctrl+C returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: SS3 Ctrl+C cancelled\n"; +} + +// 10. Zero timeout standalone Escape followed by newline must cancel +try { + $t = makeStreamTerminal("\x1b\n"); + $sec = $t->readSecret(Duration::fromSeconds(0)); + echo "FAIL: Zero timeout Escape returned secret: " . bin2hex($sec) . "\n"; +} catch (TerminalException $e) { + echo "PASS: Zero timeout Escape cancelled\n"; +} + +// 11. Zero timeout navigation sequence \x1b[A followed by text must ignore navigation and return text +$t = makeStreamTerminal("\x1b[Apass\n"); +$sec = $t->readSecret(Duration::fromSeconds(0)); +var_dump($sec === "pass"); + +// 12. Zero timeout SS3 F1 followed by text must ignore F1 and return text +$t = makeStreamTerminal("\x1bOPpass\n"); +$sec = $t->readSecret(Duration::fromSeconds(0)); +var_dump($sec === "pass"); + +?> +--EXPECT-- +PASS: Ctrl+C cancelled +PASS: Ctrl+D cancelled +PASS: Escape cancelled +bool(true) +bool(true) +bool(true) +bool(true) +PASS: CSI Ctrl+C cancelled +PASS: SS3 Ctrl+C cancelled +PASS: Zero timeout Escape cancelled +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt b/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt new file mode 100644 index 000000000000..2c6d2b8663a4 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt @@ -0,0 +1,129 @@ +--TEST-- +Io\Terminal\Terminal: ordinary-stream sequence timeout on partial Escape/CSI/UTF-8 +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0), Duration::fromSeconds(0)); +$elapsed0 = (microtime(true) - $t0) * 1000; +var_dump($key0 === Key::Escape); +var_dump($elapsed0 < 100); + +// Test 2: Standalone Escape with sequence timeout returns Escape after sequence delay, not blocking forever +fwrite($writeStream, "\x1b"); +$t1 = microtime(true); +$key1 = $term->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(60)); +$elapsed1 = (microtime(true) - $t1) * 1000; +var_dump($key1 === Key::Escape); +var_dump($elapsed1 >= 40 && $elapsed1 < 400); + +// Test 3: Partial CSI "\x1b[" on pipe returns after sequence timeout +fwrite($writeStream, "\x1b["); +$t2 = microtime(true); +$key2 = $term->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(60)); +$elapsed2 = (microtime(true) - $t2) * 1000; +var_dump($key2 === "\x1b["); +var_dump($elapsed2 >= 40 && $elapsed2 < 400); + +// Test 4: Incomplete UTF-8 prefix "\xE2" times out within deadline and retains prefix +fwrite($writeStream, "\xE2"); +$t3 = microtime(true); +$key3 = $term->readKey(Duration::fromMilliseconds(60)); +$elapsed3 = (microtime(true) - $t3) * 1000; +var_dump($key3 === null); +var_dump($elapsed3 >= 40 && $elapsed3 < 400); + +// Supply completion bytes +fwrite($writeStream, "\x82\xAC"); +$key4 = $term->readKey(Duration::fromMilliseconds(100)); +var_dump($key4 === "€"); + +fclose($readStream); +fclose($writeStream); + +// Test 5: Incomplete CSI sequence in readSecret times out within overall deadline +$pair5 = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +$read5 = $pair5[0]; +$write5 = $pair5[1]; +$term5 = Terminal::fromStreams($read5); + +fwrite($write5, "\x1b[1;2;3"); +$t5 = microtime(true); +$sec5 = $term5->readSecret(Duration::fromMilliseconds(150)); +$elapsed5 = (microtime(true) - $t5) * 1000; +var_dump($sec5 === null); +var_dump($elapsed5 >= 100 && $elapsed5 < 600); + +fclose($read5); +fclose($write5); + +// Test 6: A producer continuously supplies non-final CSI bytes for roughly two seconds; +// readSecret with a 150ms timeout must return null well before producer finishes +$proc6 = proc_open( + [PHP_BINARY, "-n", "-r", "fwrite(STDOUT, \"\\x1b[\"); \$t = microtime(true); while (microtime(true) - \$t < 2.0) { fwrite(STDOUT, \"1;2\"); usleep(5000); } fwrite(STDOUT, \"m\");"], + [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]], + $pipes6 +); + +if (is_resource($proc6)) { + $read6 = $pipes6[1]; + $term6 = Terminal::fromStreams($read6); + $t6 = microtime(true); + $sec6 = $term6->readSecret(Duration::fromMilliseconds(150)); + $elapsed6 = (microtime(true) - $t6) * 1000; + var_dump($sec6 === null); + var_dump($elapsed6 >= 100 && $elapsed6 < 800); + fclose($pipes6[0]); + fclose($pipes6[1]); + fclose($pipes6[2]); + proc_terminate($proc6); + proc_close($proc6); +} + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_stream_timeout.phpt b/ext/standard/tests/terminal/terminal_stream_timeout.phpt new file mode 100644 index 000000000000..0373a27d0684 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_stream_timeout.phpt @@ -0,0 +1,121 @@ +--TEST-- +Io\Terminal\Terminal: ordinary-stream timeout on empty pipe with open writer +--SKIPIF-- + +--FILE-- +readKey(Duration::fromSeconds(0)); +$elapsed0 = (microtime(true) - $t0) * 1000; +var_dump($key0 === null); +var_dump($elapsed0 < 200); + +// Test 2: Empty pipe with finite timeout returns null after bounded wait +$t1 = microtime(true); +$key1 = $term->readKey(Duration::fromMilliseconds(50)); +$elapsed1 = (microtime(true) - $t1) * 1000; +var_dump($key1 === null); +var_dump($elapsed1 >= 30 && $elapsed1 < 300); + +// Test 3: Queued byte is immediately readable +fwrite($writeStream, "Z"); +$t2 = microtime(true); +$key2 = $term->readKey(Duration::fromMilliseconds(200)); +$elapsed2 = (microtime(true) - $t2) * 1000; +var_dump($key2 === "Z"); +var_dump($elapsed2 < 100); + +// Test 4: readSecret on empty pipe with finite timeout returns null after bounded wait +$t3 = microtime(true); +$sec3 = $term->readSecret(Duration::fromMilliseconds(50)); +$elapsed3 = (microtime(true) - $t3) * 1000; +var_dump($sec3 === null); +var_dump($elapsed3 >= 30 && $elapsed3 < 300); + +fclose($readStream); +fclose($writeStream); + +// Part 2: Anonymous pipe via proc_open +$proc = proc_open( + [PHP_BINARY, '-r', 'usleep(2000000);'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $procPipes +); +if (!is_resource($proc)) { + die("Failed to proc_open child"); +} + +$pipeTerm = Terminal::fromStreams($procPipes[1]); + +// Test 5: Empty anonymous pipe with zero timeout returns null immediately (not blocking) +$t4 = microtime(true); +$key4 = $pipeTerm->readKey(Duration::fromSeconds(0)); +$elapsed4 = (microtime(true) - $t4) * 1000; +var_dump($key4 === null); +var_dump($elapsed4 < 200); + +// Test 6: Empty anonymous pipe with finite timeout returns null after bounded wait +$t5 = microtime(true); +$key5 = $pipeTerm->readKey(Duration::fromMilliseconds(50)); +$elapsed5 = (microtime(true) - $t5) * 1000; +var_dump($key5 === null); +var_dump($elapsed5 >= 30 && $elapsed5 < 300); + +// Test 7: readSecret on empty anonymous pipe with finite timeout returns null after bounded wait +$t6 = microtime(true); +$sec6 = $pipeTerm->readSecret(Duration::fromMilliseconds(50)); +$elapsed6 = (microtime(true) - $t6) * 1000; +var_dump($sec6 === null); +var_dump($elapsed6 >= 30 && $elapsed6 < 300); + +fclose($procPipes[0]); +fclose($procPipes[1]); +fclose($procPipes[2]); +proc_terminate($proc); +proc_close($proc); + +?> +--EXPECTF-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) From f025a90b23d7db8ce95bc2f2f3a41f2328d22449 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 02:49:28 +0545 Subject: [PATCH 40/52] test(terminal): Fix GNU stty output parsing and eliminate PTY EOF race condition --- .../terminal/terminal_raw_mode_output_flags.phpt | 4 +++- .../tests/terminal/terminal_read_key_contracts.phpt | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt index 1cb23c002156..9a86dec8924a 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt @@ -37,7 +37,9 @@ function getOpostState($stream): bool { fclose($pipes[1]); fclose($pipes[2]); proc_close($sub); - return strpos($out, " opost") !== false && strpos($out, " -opost") === false; + $hasOpost = preg_match('/(?:\s|^)opost(?:\s|$)/', $out) === 1; + $hasMinusOpost = preg_match('/(?:\s|^)-opost(?:\s|$)/', $out) === 1; + return $hasOpost && !$hasMinusOpost; } function setOpostState($stream, bool $enable): void { diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt index 7530986edbf8..1a532f6524b2 100644 --- a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -91,14 +91,17 @@ var_dump($keyNonTty === "z"); fclose($fp); // Contract 5: EOF / stream termination throws TerminalException on real TTY -fwrite($pipes[0], "exit -"); -usleep(50000); // give child time to exit +if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]); +if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]); +proc_terminate($proc); +$deadline = hrtime(true) + 1_000_000_000; +while (proc_get_status($proc)['running'] && hrtime(true) < $deadline) { + usleep(5000); +} try { $terminal->readKey(Duration::fromSeconds(1)); - echo "FAIL: readKey on closed/EOF terminal did not throw -"; + echo "FAIL: readKey on closed/EOF terminal did not throw\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } From fe88929bb4155b2e7071b0d860d99257dd706051 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 07:42:15 +0545 Subject: [PATCH 41/52] fix(terminal): Suppress plain_wrapper stream notices on terminal EOF and EIO --- ext/standard/io_terminal.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index ead42659c1c9..424d33b79d75 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1624,7 +1624,10 @@ static int php_io_terminal_read_byte( ) { if (stream != NULL && stream->writepos > stream->readpos) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + stream->flags = old_flags; if (read_bytes == 1) { return PHP_IO_TERMINAL_READ_SUCCESS; } @@ -1776,7 +1779,10 @@ static int php_io_terminal_read_byte( if (poll_ctx == NULL) { if (stream != NULL) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; size_t read_bytes = php_stream_read(stream, (char *) byte, 1); + stream->flags = old_flags; if (read_bytes == 1) { return PHP_IO_TERMINAL_READ_SUCCESS; } @@ -1797,6 +1803,11 @@ static int php_io_terminal_read_byte( if (timeout != NULL) { return PHP_IO_TERMINAL_READ_TIMEOUT; } +#ifdef EIO + if (bytes_read < 0 && errno == EIO) { + return PHP_IO_TERMINAL_READ_EOF; + } +#endif return bytes_read == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; } @@ -1852,7 +1863,10 @@ static int php_io_terminal_read_byte( } if (stream != NULL) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; size_t n = php_stream_read(stream, (char *) byte, 1); + stream->flags = old_flags; if (n == 1) { return PHP_IO_TERMINAL_READ_SUCCESS; } @@ -1867,6 +1881,11 @@ static int php_io_terminal_read_byte( return PHP_IO_TERMINAL_READ_EOF; } if (bytes_read < 0) { +#ifdef EIO + if (errno == EIO) { + return PHP_IO_TERMINAL_READ_EOF; + } +#endif return PHP_IO_TERMINAL_READ_ERROR; } @@ -3444,7 +3463,10 @@ static char *php_io_terminal_read_line_posix( bool is_tty = php_io_terminal_native_stream_is_tty(fd); if (!is_tty) { if (stream != NULL) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; char *buf = php_stream_get_line(stream, NULL, 0, out_len); + stream->flags = old_flags; if (buf == NULL) { if (php_stream_eof(stream)) { *is_eof = true; @@ -3470,6 +3492,8 @@ static char *php_io_terminal_read_line_posix( /* Drain any already-buffered bytes from php_stream first */ if (stream != NULL && stream->writepos > stream->readpos) { + int old_flags = stream->flags; + stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; while (stream->writepos > stream->readpos) { size_t read_bytes = php_stream_read(stream, &ch, 1); if (read_bytes != 1) { @@ -3480,6 +3504,7 @@ static char *php_io_terminal_read_line_posix( break; } } + stream->flags = old_flags; } if (stream != NULL && php_stream_eof(stream)) { From acf7cc2ecec9e6fbb2e4a812f1ab80c71b0ecedb Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 07:48:05 +0545 Subject: [PATCH 42/52] fix(terminal): Narrow stream notice suppression to the POSIX byte reads Drop the readLine() suppression and the EIO-to-EOF conversion from fe88929bb41; they were broader than the CI failure required and could hide real read errors. --- ext/standard/io_terminal.c | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 424d33b79d75..4d25d64f638b 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1624,10 +1624,7 @@ static int php_io_terminal_read_byte( ) { if (stream != NULL && stream->writepos > stream->readpos) { - int old_flags = stream->flags; - stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; size_t read_bytes = php_stream_read(stream, (char *) byte, 1); - stream->flags = old_flags; if (read_bytes == 1) { return PHP_IO_TERMINAL_READ_SUCCESS; } @@ -1803,11 +1800,6 @@ static int php_io_terminal_read_byte( if (timeout != NULL) { return PHP_IO_TERMINAL_READ_TIMEOUT; } -#ifdef EIO - if (bytes_read < 0 && errno == EIO) { - return PHP_IO_TERMINAL_READ_EOF; - } -#endif return bytes_read == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR; } @@ -1881,11 +1873,6 @@ static int php_io_terminal_read_byte( return PHP_IO_TERMINAL_READ_EOF; } if (bytes_read < 0) { -#ifdef EIO - if (errno == EIO) { - return PHP_IO_TERMINAL_READ_EOF; - } -#endif return PHP_IO_TERMINAL_READ_ERROR; } @@ -3463,10 +3450,7 @@ static char *php_io_terminal_read_line_posix( bool is_tty = php_io_terminal_native_stream_is_tty(fd); if (!is_tty) { if (stream != NULL) { - int old_flags = stream->flags; - stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; char *buf = php_stream_get_line(stream, NULL, 0, out_len); - stream->flags = old_flags; if (buf == NULL) { if (php_stream_eof(stream)) { *is_eof = true; @@ -3492,8 +3476,6 @@ static char *php_io_terminal_read_line_posix( /* Drain any already-buffered bytes from php_stream first */ if (stream != NULL && stream->writepos > stream->readpos) { - int old_flags = stream->flags; - stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS; while (stream->writepos > stream->readpos) { size_t read_bytes = php_stream_read(stream, &ch, 1); if (read_bytes != 1) { @@ -3504,7 +3486,6 @@ static char *php_io_terminal_read_line_posix( break; } } - stream->flags = old_flags; } if (stream != NULL && php_stream_eof(stream)) { From 2af04bdeb01dbda68f65e40e5b8822a80bc0c161 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 10:33:01 +0545 Subject: [PATCH 43/52] fix(terminal): Align ordinary-stream readKey EOF to RFC and complete raw mode checks --- ext/standard/io_terminal.c | 70 ++++++-- .../terminal_raw_mode_isig_checks.phpt | 168 ++++++++++++++++++ ...terminal_read_key_ordinary_stream_eof.phpt | 158 ++++++++++++++++ 3 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt create mode 100644 ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 4d25d64f638b..072d5be06907 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -917,8 +917,11 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( } if (is_console) { 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); + if (!GetConsoleMode(handle, ¤t_mode)) { + return NULL; + } + DWORD raw = php_io_terminal_make_raw_mode(current_mode); + if (current_mode != raw) { if (!SetConsoleMode(handle, raw)) { return NULL; } @@ -1396,6 +1399,30 @@ static void php_io_terminal_make_raw_mode(struct termios *mode) mode->c_cc[VTIME] = 0; } +static bool php_io_terminal_termios_needs_raw_mode(const struct termios *current, const struct termios *desired) +{ + tcflag_t iflag_mask = IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON; + tcflag_t lflag_mask = ECHO | ECHONL | ICANON | ISIG | IEXTEN; + tcflag_t cflag_mask = CSIZE | PARENB; + + if ((current->c_iflag & iflag_mask) != (desired->c_iflag & iflag_mask)) { + return true; + } + if ((current->c_lflag & lflag_mask) != (desired->c_lflag & lflag_mask)) { + return true; + } + if ((current->c_cflag & cflag_mask) != (desired->c_cflag & cflag_mask)) { + return true; + } + if (current->c_cc[VMIN] != desired->c_cc[VMIN]) { + return true; + } + if (current->c_cc[VTIME] != desired->c_cc[VTIME]) { + return true; + } + return false; +} + static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( php_io_terminal_native_stream fd, php_stream *stream, @@ -1411,9 +1438,12 @@ static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode( } if (is_tty) { 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); + if (tcgetattr(fd, ¤t_mode) != 0) { + return NULL; + } + struct termios raw = current_mode; + php_io_terminal_make_raw_mode(&raw); + if (php_io_terminal_termios_needs_raw_mode(¤t_mode, &raw)) { if (tcsetattr(fd, TCSANOW, &raw) != 0) { return NULL; } @@ -2223,9 +2253,13 @@ static zend_string *php_io_terminal_read_stream_key( php_poll_ctx *poll_ctx = NULL; if (is_tty) { - if (tcgetattr(fd, &mode) == 0 && (mode.c_lflag & (ECHO | ICANON)) != 0) { - raw_mode = mode; - php_io_terminal_make_raw_mode(&raw_mode); + if (tcgetattr(fd, &mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to get terminal mode", 0); + return NULL; + } + raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + if (php_io_terminal_termios_needs_raw_mode(&mode, &raw_mode)) { if (tcsetattr(fd, TCSANOW, &raw_mode) == 0) { mode_changed = true; } else { @@ -2284,12 +2318,7 @@ static zend_string *php_io_terminal_read_stream_key( ); break; } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { - if (is_tty) { - zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); - } else { - /* Non-TTY stream at EOF returns null */ - result = NULL; - } + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); break; } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { result = NULL; @@ -2350,12 +2379,14 @@ static zend_string *php_io_terminal_read_stream_secret( if (is_tty) { if (tcgetattr(fd, &mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to get terminal mode", 0); return NULL; } - if ((mode.c_lflag & (ECHO | ICANON)) != 0) { - raw_mode = mode; - php_io_terminal_make_raw_mode(&raw_mode); + raw_mode = mode; + php_io_terminal_make_raw_mode(&raw_mode); + if (php_io_terminal_termios_needs_raw_mode(&mode, &raw_mode)) { if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0); return NULL; } mode_changed = true; @@ -2683,6 +2714,9 @@ static zend_string *php_io_terminal_read_stream_secret( if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) { success = false; *timed_out = false; + if (!EG(exception)) { + zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0); + } } if (poll_ctx != NULL) { @@ -3069,7 +3103,7 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) ); break; } else if (read_result == PHP_IO_TERMINAL_READ_EOF) { - key = NULL; + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); break; } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { key = NULL; diff --git a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt new file mode 100644 index 000000000000..73f019017a2b --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt @@ -0,0 +1,168 @@ +--TEST-- +Io\Terminal\Terminal: raw mode configuration when ECHO and ICANON are disabled but ISIG is enabled +--SKIPIF-- + ['pty'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); +} catch (Throwable) { + die("skip PTY not available"); +} +if (!is_resource($proc)) { + die("skip PTY not available"); +} +foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } +proc_close($proc); +?> +--FILE-- + ['pty'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = proc_open([PHP_BINARY, '-r', $code], $descriptors, $pipes); + if (!is_resource($proc)) { + return "FAIL: proc_open failed"; + } + + // Readiness synchronization: wait for child to signal READY + stream_set_timeout($pipes[1], 3); + $ready = fgets($pipes[1]); + if (trim($ready) !== 'READY') { + foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } + proc_terminate($proc); + proc_close($proc); + return "FAIL: child not ready: " . var_export($ready, true); + } + + if ($inputToSend !== null) { + usleep(50000); + fwrite($pipes[0], $inputToSend); + fflush($pipes[0]); + } + + // Read result + $result = fgets($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + + foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } + proc_close($proc); + + if (!empty($stderr)) { + return "STDERR: " . trim($stderr); + } + + return trim($result); +} + +// Child helper functions template +$setupHelper = ' +function setSttyMode(): void { + $sub = proc_open(["stty", "-echo", "-icanon", "isig"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp); + if (is_resource($sub)) { + fclose($sp[1]); fclose($sp[2]); proc_close($sub); + } +} +function isPrecedingModeRestored(): bool { + $sub = proc_open(["stty", "-a"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp); + if (!is_resource($sub)) return false; + $out = stream_get_contents($sp[1]); + fclose($sp[1]); fclose($sp[2]); proc_close($sub); + $hasIsig = preg_match("/(?:\s|^)isig(?:\s|$)/", $out) === 1 && preg_match("/(?:\s|^)-isig(?:\s|$)/", $out) !== 1; + $hasMinusEcho = strpos($out, "-echo") !== false; + $hasMinusIcanon = strpos($out, "-icanon") !== false; + return $hasIsig && $hasMinusEcho && $hasMinusIcanon; +} +'; + +// Case 1: readKey() receives Ctrl+C as input byte and restores preceding mode +$codeKey = $setupHelper . ' +use Io\Terminal\Terminal; +use Time\Duration; +if (function_exists("pcntl_alarm")) pcntl_alarm(5); +setSttyMode(); +$t = Terminal::fromStdio(); +fwrite(STDOUT, "READY\n"); +fflush(STDOUT); +$key = $t->readKey(Duration::fromSeconds(2)); +$restored = isPrecedingModeRestored(); +echo ($key === "\x03" && $restored) ? "PASS: readKey received Ctrl+C and restored mode" : "FAIL: key=" . bin2hex((string)$key) . " restored=" . ($restored ? "1" : "0"); +'; + +echo runIsolatedChild($codeKey, "\x03"), "\n"; + +// Case 2: readSecret() handles Ctrl+C through TerminalException and restores preceding mode +$codeSecret = $setupHelper . ' +use Io\Terminal\Terminal; +use Io\Terminal\TerminalException; +use Time\Duration; +if (function_exists("pcntl_alarm")) pcntl_alarm(5); +setSttyMode(); +$t = Terminal::fromStdio(); +fwrite(STDOUT, "READY\n"); +fflush(STDOUT); +$caught = false; +try { + $t->readSecret(Duration::fromSeconds(2)); +} catch (TerminalException $e) { + $caught = true; +} +$restored = isPrecedingModeRestored(); +echo ($caught && $restored) ? "PASS: readSecret threw TerminalException on Ctrl+C and restored mode" : "FAIL: caught=" . ($caught ? "1" : "0") . " restored=" . ($restored ? "1" : "0"); +'; + +echo runIsolatedChild($codeSecret, "\x03"), "\n"; + +// Case 3: Shared-lease reapplication path applies raw mode when ISIG is enabled and restores afterward +$codeLease = $setupHelper . ' +use Io\Terminal\Terminal; +if (function_exists("pcntl_alarm")) pcntl_alarm(5); +setSttyMode(); +$term = Terminal::fromStdio(); +fwrite(STDOUT, "READY\n"); +fflush(STDOUT); +$t1 = $term->enableRawMode(); + +// Alter terminal mode back to -echo -icanon isig +setSttyMode(); + +// Reapply lease while first lease is still active +$t2 = $term->enableRawMode(); + +// Verify ISIG is now disabled in terminal +$sub = proc_open(["stty", "-a"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp); +$out = stream_get_contents($sp[1]); +fclose($sp[1]); fclose($sp[2]); proc_close($sub); +$hasMinusIsig = strpos($out, "-isig") !== false; + +$term->restoreMode($t2); +$term->restoreMode($t1); + +$restored = isPrecedingModeRestored(); +echo ($hasMinusIsig && $restored) ? "PASS: lease reapplication disabled ISIG and restored mode" : "FAIL: raw=" . ($hasMinusIsig ? "1" : "0") . " restored=" . ($restored ? "1" : "0"); +'; + +echo runIsolatedChild($codeLease, null), "\n"; +?> +--EXPECT-- +PASS: readKey received Ctrl+C and restored mode +PASS: readSecret threw TerminalException on Ctrl+C and restored mode +PASS: lease reapplication disabled ISIG and restored mode diff --git a/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt b/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt new file mode 100644 index 000000000000..708b478616c2 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt @@ -0,0 +1,158 @@ +--TEST-- +Io\Terminal\Terminal: readKey EOF behavior on ordinary streams +--SKIPIF-- + +--FILE-- +readKey(); + echo "FAIL: default timeout on empty stream did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: default timeout immediate EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Zero timeout +$fp = fopen('php://temp', 'r+'); +$term = Terminal::fromStreams($fp); +try { + $term->readKey(Duration::fromSeconds(0)); + echo "FAIL: zero timeout on empty stream did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: zero timeout immediate EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Positive timeout +$fp = fopen('php://temp', 'r+'); +$term = Terminal::fromStreams($fp); +try { + $term->readKey(Duration::fromMilliseconds(50)); + echo "FAIL: positive timeout on empty stream did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: positive timeout immediate EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// 2. Reading an available character successfully, then encountering EOF: +// Zero timeout on subsequent read +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "A"); +rewind($fp); +$term = Terminal::fromStreams($fp); +$key = $term->readKey(Duration::fromSeconds(0)); +var_dump($key === "A"); +try { + $term->readKey(Duration::fromSeconds(0)); + echo "FAIL: subsequent zero timeout read did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: subsequent zero timeout read EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Positive timeout on subsequent read +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "B"); +rewind($fp); +$term = Terminal::fromStreams($fp); +$key = $term->readKey(Duration::fromMilliseconds(50)); +var_dump($key === "B"); +try { + $term->readKey(Duration::fromMilliseconds(50)); + echo "FAIL: subsequent positive timeout read did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: subsequent positive timeout read EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// Default timeout on subsequent read +$fp = fopen('php://temp', 'r+'); +fwrite($fp, "C"); +rewind($fp); +$term = Terminal::fromStreams($fp); +$key = $term->readKey(); +var_dump($key === "C"); +try { + $term->readKey(); + echo "FAIL: subsequent default timeout read did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: subsequent default timeout read EOF: " . $e->getMessage() . "\n"; +} +fclose($fp); + +// 3. An open input with no data yet still returning null on timeout: +$pair = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +if ($pair === false) { + die("Failed to create socket pair"); +} + +$readStream = $pair[0]; +$writeStream = $pair[1]; +$socketTerm = Terminal::fromStreams($readStream); + +// Zero timeout returns null immediately when open but no data +$kZero = $socketTerm->readKey(Duration::fromSeconds(0)); +var_dump($kZero === null); + +// Positive timeout returns null when open but no data +$kPos = $socketTerm->readKey(Duration::fromMilliseconds(30)); +var_dump($kPos === null); + +// Close writer to signal EOF on the open reader +fclose($writeStream); + +// Now that writer is closed, reading encounters EOF and throws TerminalException +try { + $socketTerm->readKey(Duration::fromSeconds(0)); + echo "FAIL: socket zero timeout EOF did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: socket zero timeout EOF: " . $e->getMessage() . "\n"; +} + +try { + $socketTerm->readKey(Duration::fromMilliseconds(30)); + echo "FAIL: socket positive timeout EOF did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: socket positive timeout EOF: " . $e->getMessage() . "\n"; +} + +try { + $socketTerm->readKey(); + echo "FAIL: socket default timeout EOF did not throw\n"; +} catch (TerminalException $e) { + echo "PASS: socket default timeout EOF: " . $e->getMessage() . "\n"; +} + +fclose($readStream); +?> +--EXPECT-- +PASS: default timeout immediate EOF: End of file reached on terminal input stream +PASS: zero timeout immediate EOF: End of file reached on terminal input stream +PASS: positive timeout immediate EOF: End of file reached on terminal input stream +bool(true) +PASS: subsequent zero timeout read EOF: End of file reached on terminal input stream +bool(true) +PASS: subsequent positive timeout read EOF: End of file reached on terminal input stream +bool(true) +PASS: subsequent default timeout read EOF: End of file reached on terminal input stream +bool(true) +bool(true) +PASS: socket zero timeout EOF: End of file reached on terminal input stream +PASS: socket positive timeout EOF: End of file reached on terminal input stream +PASS: socket default timeout EOF: End of file reached on terminal input stream From 8573efd1e10b83979848fc6664659263de677da2 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 10:57:45 +0545 Subject: [PATCH 44/52] test(terminal): Use bounded PTY observation instead of fixed sleep in raw mode ISIG checks --- .../terminal_raw_mode_isig_checks.phpt | 112 ++++++++++++++++-- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt index 73f019017a2b..8a450f03a207 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt @@ -31,6 +31,31 @@ if (function_exists('pcntl_alarm')) { pcntl_alarm(10); } +function readLineWithTimeout($stream, int $timeoutSec = 3): ?string { + $deadline = hrtime(true) + ($timeoutSec * 1_000_000_000); + while (hrtime(true) < $deadline) { + $remainingNs = $deadline - hrtime(true); + if ($remainingNs <= 0) { + break; + } + $sec = intdiv($remainingNs, 1_000_000_000); + $usec = intdiv($remainingNs % 1_000_000_000, 1000); + $r = [$stream]; + $w = null; + $e = null; + $n = @stream_select($r, $w, $e, $sec, $usec); + if ($n === false) { + continue; + } + if ($n > 0) { + $line = fgets($stream); + return ($line !== false) ? $line : null; + } + break; + } + return null; +} + function runIsolatedChild(string $code, ?string $inputToSend = null): string { $descriptors = [ 0 => ['pty'], @@ -43,28 +68,91 @@ function runIsolatedChild(string $code, ?string $inputToSend = null): string { return "FAIL: proc_open failed"; } + $cleaned = false; + $cleanup = function () use ($proc, &$pipes, &$cleaned): void { + if ($cleaned) { + return; + } + $cleaned = true; + foreach ($pipes as $p) { + if (is_resource($p)) { + fclose($p); + } + } + if (is_resource($proc)) { + $status = proc_get_status($proc); + if ($status['running']) { + $exitDeadline = hrtime(true) + 200_000_000; + while (proc_get_status($proc)['running'] && hrtime(true) < $exitDeadline) { + usleep(2000); + } + } + $status = proc_get_status($proc); + if ($status['running']) { + @proc_terminate($proc); + $termDeadline = hrtime(true) + 300_000_000; + while (proc_get_status($proc)['running'] && hrtime(true) < $termDeadline) { + usleep(2000); + } + if (proc_get_status($proc)['running'] && defined('SIGKILL')) { + @proc_terminate($proc, SIGKILL); + usleep(2000); + } + } + proc_close($proc); + } + }; + // Readiness synchronization: wait for child to signal READY - stream_set_timeout($pipes[1], 3); - $ready = fgets($pipes[1]); - if (trim($ready) !== 'READY') { - foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } - proc_terminate($proc); - proc_close($proc); - return "FAIL: child not ready: " . var_export($ready, true); + $ready = readLineWithTimeout($pipes[1], 3); + if ($ready === null || trim($ready) !== 'READY') { + stream_set_blocking($pipes[2], false); + $stderr = stream_get_contents($pipes[2]); + $cleanup(); + return "FAIL: child not ready: " . var_export($ready, true) . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : ""); } if ($inputToSend !== null) { - usleep(50000); + $isigDisabled = false; + $obsDeadline = hrtime(true) + 1_500_000_000; + while (hrtime(true) < $obsDeadline) { + $sub = @proc_open(['stty', '-a'], [0 => $pipes[0], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $subPipes); + if (is_resource($sub)) { + $out = stream_get_contents($subPipes[1]); + fclose($subPipes[1]); + fclose($subPipes[2]); + $exitCode = proc_close($sub); + if ($exitCode === 0 && preg_match('/(?:\s|^)-isig(?:\s|$)/', $out) === 1) { + $isigDisabled = true; + break; + } + } + usleep(5000); + } + + if (!$isigDisabled) { + stream_set_blocking($pipes[2], false); + $stderr = stream_get_contents($pipes[2]); + $cleanup(); + return "FAIL: timed out waiting for child to disable ISIG" . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : ""); + } + fwrite($pipes[0], $inputToSend); fflush($pipes[0]); } - // Read result - $result = fgets($pipes[1]); + // Read result with bounded timeout + $result = readLineWithTimeout($pipes[1], 3); + + // Drain stderr non-blockingly + stream_set_blocking($pipes[2], false); $stderr = stream_get_contents($pipes[2]); - foreach ($pipes as $p) { if (is_resource($p)) fclose($p); } - proc_close($proc); + $cleanup(); + + if ($result === null) { + return "FAIL: timed out or EOF reading child result" . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : ""); + } if (!empty($stderr)) { return "STDERR: " . trim($stderr); From 84b57e78f236375b40202fac4f11aeed1960265e Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Tue, 6 Oct 2026 11:32:47 +0545 Subject: [PATCH 45/52] test(terminal): Fix 32-bit intdiv TypeError in terminal_raw_mode_isig_checks --- .../terminal_raw_mode_isig_checks.phpt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt index 8a450f03a207..2dd11d857364 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt @@ -31,15 +31,23 @@ if (function_exists('pcntl_alarm')) { pcntl_alarm(10); } -function readLineWithTimeout($stream, int $timeoutSec = 3): ?string { - $deadline = hrtime(true) + ($timeoutSec * 1_000_000_000); - while (hrtime(true) < $deadline) { - $remainingNs = $deadline - hrtime(true); - if ($remainingNs <= 0) { +function getMonotonicTime(): float { + $t = hrtime(false); + return $t[0] + ($t[1] / 1e9); +} + +function readLineWithTimeout($stream, float $timeoutSec = 3.0): ?string { + $deadline = getMonotonicTime() + $timeoutSec; + while (getMonotonicTime() < $deadline) { + $remaining = $deadline - getMonotonicTime(); + if ($remaining <= 0) { break; } - $sec = intdiv($remainingNs, 1_000_000_000); - $usec = intdiv($remainingNs % 1_000_000_000, 1000); + $sec = (int) $remaining; + $usec = (int) (($remaining - $sec) * 1_000_000); + if ($usec < 0) { + $usec = 0; + } $r = [$stream]; $w = null; $e = null; @@ -82,16 +90,16 @@ function runIsolatedChild(string $code, ?string $inputToSend = null): string { if (is_resource($proc)) { $status = proc_get_status($proc); if ($status['running']) { - $exitDeadline = hrtime(true) + 200_000_000; - while (proc_get_status($proc)['running'] && hrtime(true) < $exitDeadline) { + $exitDeadline = getMonotonicTime() + 0.2; + while (proc_get_status($proc)['running'] && getMonotonicTime() < $exitDeadline) { usleep(2000); } } $status = proc_get_status($proc); if ($status['running']) { @proc_terminate($proc); - $termDeadline = hrtime(true) + 300_000_000; - while (proc_get_status($proc)['running'] && hrtime(true) < $termDeadline) { + $termDeadline = getMonotonicTime() + 0.3; + while (proc_get_status($proc)['running'] && getMonotonicTime() < $termDeadline) { usleep(2000); } if (proc_get_status($proc)['running'] && defined('SIGKILL')) { @@ -104,7 +112,7 @@ function runIsolatedChild(string $code, ?string $inputToSend = null): string { }; // Readiness synchronization: wait for child to signal READY - $ready = readLineWithTimeout($pipes[1], 3); + $ready = readLineWithTimeout($pipes[1], 3.0); if ($ready === null || trim($ready) !== 'READY') { stream_set_blocking($pipes[2], false); $stderr = stream_get_contents($pipes[2]); @@ -114,8 +122,8 @@ function runIsolatedChild(string $code, ?string $inputToSend = null): string { if ($inputToSend !== null) { $isigDisabled = false; - $obsDeadline = hrtime(true) + 1_500_000_000; - while (hrtime(true) < $obsDeadline) { + $obsDeadline = getMonotonicTime() + 1.5; + while (getMonotonicTime() < $obsDeadline) { $sub = @proc_open(['stty', '-a'], [0 => $pipes[0], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $subPipes); if (is_resource($sub)) { $out = stream_get_contents($subPipes[1]); @@ -142,7 +150,7 @@ function runIsolatedChild(string $code, ?string $inputToSend = null): string { } // Read result with bounded timeout - $result = readLineWithTimeout($pipes[1], 3); + $result = readLineWithTimeout($pipes[1], 3.0); // Drain stderr non-blockingly stream_set_blocking($pipes[2], false); From 84a09f1a5e8cb9675409f905c49a276ab2720835 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sat, 24 Oct 2026 07:54:38 +0545 Subject: [PATCH 46/52] Fix zero-timeout escape decoding and preserve pending resize across getSize() --- ext/standard/io_terminal.c | 36 +- ...minal_read_key_zero_timeout_sequences.phpt | 327 ++++++++++++++++++ .../terminal/terminal_resize_getsize.phpt | 165 +++++++++ 3 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt create mode 100644 ext/standard/tests/terminal/terminal_resize_getsize.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 072d5be06907..0082734a5ed7 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1984,13 +1984,14 @@ static zend_string *php_io_terminal_key_from_escape_sequence( deadline_ns = overall_deadline_ns; } + struct timespec remaining_ts; if (now >= deadline_ns) { - return ZSTR_INIT_LITERAL("escape", false); + remaining_ts.tv_sec = 0; + remaining_ts.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); } - 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); if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT || read_result == PHP_IO_TERMINAL_READ_EOF) { @@ -2006,9 +2007,11 @@ static zend_string *php_io_terminal_key_from_escape_sequence( while (seq_len < sizeof(seq)) { now = zend_hrtime(); if (now >= deadline_ns) { - break; + remaining_ts.tv_sec = 0; + remaining_ts.tv_nsec = 0; + } else { + php_io_terminal_ns_to_timespec(deadline_ns - now, &remaining_ts); } - 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) { if (read_result == PHP_IO_TERMINAL_READ_EOF) { @@ -2063,12 +2066,13 @@ 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)) { 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 (now >= deadline_ns) { + remaining_ts.tv_sec = 0; + remaining_ts.tv_nsec = 0; } else { - read_result = PHP_IO_TERMINAL_READ_TIMEOUT; + 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]) { @@ -2869,9 +2873,11 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) RETURN_NULL(); } - intern->last_cols = columns; - intern->last_rows = rows; - intern->has_last_size = true; + if (!intern->has_last_size) { + intern->last_cols = columns; + intern->last_rows = rows; + intern->has_last_size = true; + } zval params[2]; ZVAL_LONG(¶ms[0], columns); @@ -3643,7 +3649,7 @@ PHP_METHOD(Io_Terminal_Terminal, readLine) } } else { int ch = php_stream_getc(stream.php_stream); - if (ch != '\n' && ch != EOF) { + if (ch != '\n' && ch != EOF && intern->pending_utf8.length < sizeof(intern->pending_utf8.bytes)) { intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; } } @@ -3689,7 +3695,7 @@ PHP_METHOD(Io_Terminal_Terminal, readLine) } } else { int ch = php_stream_getc(stream.php_stream); - if (ch != '\n' && ch != EOF) { + if (ch != '\n' && ch != EOF && intern->pending_utf8.length < sizeof(intern->pending_utf8.bytes)) { intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; } } diff --git a/ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt new file mode 100644 index 000000000000..65b299cebb45 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt @@ -0,0 +1,327 @@ +--TEST-- +Io\Terminal\Terminal: readKey zero-timeout and sequenceTimeout zero escape sequence decoding +--SKIPIF-- + +--FILE-- +name; + } + if (is_string($key)) { + return 'str:' . bin2hex($key); + } + return 'null'; +} + +$zero = Duration::fromSeconds(0); +$posTimeout = Duration::fromMilliseconds(200); +$posSeqTimeout = Duration::fromMilliseconds(400); + +foreach ([false, true] as $unbuffered) { + $modeLabel = $unbuffered ? 'unbuffered' : 'buffered'; + echo "=== Mode: {$modeLabel} ===\n"; + + // 1. Complete CSI and SS3 sequences with overall timeout zero + $sequences = [ + 'Up' => "\x1b[A", + 'Down' => "\x1b[B", + 'Right' => "\x1b[C", + 'Left' => "\x1b[D", + 'Insert' => "\x1b[2~", + 'ShiftTab' => "\x1b[Z", + 'F1_SS3' => "\x1bOP", + 'F1_CSI' => "\x1b[11~", + 'Unsupported_CSI' => "\x1b[1;5A", + ]; + + foreach ($sequences as $label => $bytes) { + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, $bytes); + fflush($w); + usleep(2000); + $k = $term->readKey($zero); + $rem = $term->readKey($zero); + echo "overall=0 {$label}: " . formatKey($k) . " next=" . formatKey($rem) . "\n"; + fclose($r); + fclose($w); + } + + // 2. Consecutive sequences followed by ordinary text, preserving subsequent input + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, "\x1b[A\x1b[B\x1b[C\x1b[D\x1b[2~\x1b[Z\x1bOP\x1b[11~ab"); + fflush($w); + usleep(2000); + + $consecutive = []; + for ($i = 0; $i < 11; $i++) { + $consecutive[] = formatKey($term->readKey($zero)); + } + echo "consecutive overall=0: " . implode(', ', $consecutive) . "\n"; + fclose($r); + fclose($w); + + // 3. Lone Escape and incomplete prefixes with overall timeout zero (immediate fallback) + $fallbacks = [ + 'Lone_ESC' => "\x1b", + 'Incomplete_CSI_bracket' => "\x1b[", + 'Incomplete_CSI_params' => "\x1b[1;", + 'Incomplete_SS3' => "\x1bO", + ]; + foreach ($fallbacks as $label => $bytes) { + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, $bytes); + fflush($w); + usleep(2000); + + $t0 = hrtime(true); + $k1 = $term->readKey($zero); + $elapsedMs = (hrtime(true) - $t0) / 1e6; + $k2 = $term->readKey($zero); + echo "fallback overall=0 {$label}: " . formatKey($k1) + . " next=" . formatKey($k2) + . " fast=" . ($elapsedMs < 100.0 ? 'true' : 'false') . "\n"; + fclose($r); + fclose($w); + } + + // 4. Explicit sequenceTimeout zero with positive overall timeout + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, "\x1b[A\x1b[2~\x1b[Z\x1bOP\x1b[11~z"); + fflush($w); + usleep(2000); + + $seqZeroResults = []; + for ($i = 0; $i < 6; $i++) { + $seqZeroResults[] = formatKey($term->readKey($posTimeout, $zero)); + } + echo "seqTimeout=0 complete: " . implode(', ', $seqZeroResults) . "\n"; + fclose($r); + fclose($w); + + foreach ($fallbacks as $label => $bytes) { + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, $bytes); + fflush($w); + usleep(2000); + + $t0 = hrtime(true); + $k1 = $term->readKey($posTimeout, $zero); + $elapsedMs = (hrtime(true) - $t0) / 1e6; + $k2 = $term->readKey($zero); + echo "fallback seqTimeout=0 {$label}: " . formatKey($k1) + . " next=" . formatKey($k2) + . " fast=" . ($elapsedMs < 100.0 ? 'true' : 'false') . "\n"; + fclose($r); + fclose($w); + } + + // 5. Overall zero timeout remains nonblocking even when sequenceTimeout is positive + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, "\x1b[A\x1bOPq"); + fflush($w); + usleep(2000); + echo "overall=0,seq>0 complete: " + . formatKey($term->readKey($zero, $posSeqTimeout)) . ", " + . formatKey($term->readKey($zero, $posSeqTimeout)) . ", " + . formatKey($term->readKey($zero, $posSeqTimeout)) . "\n"; + fclose($r); + fclose($w); + + foreach ($fallbacks as $label => $bytes) { + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, $bytes); + fflush($w); + usleep(2000); + + $t0 = hrtime(true); + $k1 = $term->readKey($zero, $posSeqTimeout); + $elapsedMs = (hrtime(true) - $t0) / 1e6; + $k2 = $term->readKey($zero, $posSeqTimeout); + echo "overall=0,seq>0 {$label}: " . formatKey($k1) + . " next=" . formatKey($k2) + . " nonblocking=" . ($elapsedMs < 100.0 ? 'true' : 'false') . "\n"; + fclose($r); + fclose($w); + } + + // 6. Malformed, overlong (>32 bytes), and invalid UTF-8 / truncated EOF sequences + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + $overlong = "\x1b[" . str_repeat("1;", 16) . "A!"; // 2 + 32 + 2 = 36 bytes + fwrite($w, $overlong); + fflush($w); + usleep(2000); + $firstChunk = $term->readKey($zero); + $rest = []; + while (($nextKey = $term->readKey($zero)) !== null) { + $rest[] = $nextKey; + } + echo "overlong CSI: len=" . strlen($firstChunk) + . " prefix_ok=" . (str_starts_with($overlong, $firstChunk) ? 'true' : 'false') + . " tail=" . implode('', $rest) . "\n"; + fclose($r); + fclose($w); + + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, "\x1bx\x1bOX\x80\xff\xe2A"); + fflush($w); + usleep(2000); + $malformed = []; + while (($mk = $term->readKey($zero)) !== null) { + $malformed[] = formatKey($mk); + } + echo "malformed+invalid_utf8: " . implode(', ', $malformed) . "\n"; + fclose($r); + fclose($w); + + // Fragmented UTF-8 retained on zero-timeout read and completed on next zero-timeout read + [$r, $w] = makeSocketPair($unbuffered); + $term = Terminal::fromStreams($r); + fwrite($w, "\xe2"); + fflush($w); + usleep(2000); + $u1 = $term->readKey($zero); + fwrite($w, "\x82\xac"); + fflush($w); + usleep(2000); + $u2 = $term->readKey($zero); + echo "utf8_retained_zero_timeout: first=" . formatKey($u1) . " second=" . formatKey($u2) . "\n"; + fclose($w); + fclose($r); +} + +// 7. Deterministic bounded fuzzing across random escape/UTF-8/control payloads +mt_srand(0x5EED1234); +$alphabet = ["\x1b", "[", "O", "1", ";", "2", "~", "A", "B", "P", "Z", "\x00", "\x80", "\xc2\xa3", "\xe2\x82\xac", "\xf0\x9f\x98\x80", "\xff", "x"]; +$fuzzOk = true; +for ($iter = 0; $iter < 100; $iter++) { + [$r, $w] = makeSocketPair(($iter % 2) === 1); + $term = Terminal::fromStreams($r); + $count = mt_rand(1, 24); + $payload = ''; + for ($b = 0; $b < $count; $b++) { + $payload .= $alphabet[mt_rand(0, count($alphabet) - 1)]; + } + fwrite($w, $payload); + fflush($w); + usleep(1000); + + $steps = 0; + $maxSteps = strlen($payload) + 2; + while ($steps <= $maxSteps) { + $steps++; + $res = $term->readKey($zero); + if ($res === null) { + break; + } + } + if ($steps > $maxSteps) { + $fuzzOk = false; + } + fclose($w); + try { + $term->readKey($zero); + $fuzzOk = false; + } catch (TerminalException) { + // Expected EOF + } + fclose($r); +} +echo "bounded_fuzz_100: " . ($fuzzOk ? 'PASS' : 'FAIL') . "\n"; +?> +--EXPECT-- +=== Mode: buffered === +overall=0 Up: Key::Up next=null +overall=0 Down: Key::Down next=null +overall=0 Right: Key::Right next=null +overall=0 Left: Key::Left next=null +overall=0 Insert: Key::Insert next=null +overall=0 ShiftTab: Key::ShiftTab next=null +overall=0 F1_SS3: Key::F1 next=null +overall=0 F1_CSI: Key::F1 next=null +overall=0 Unsupported_CSI: str:1b5b313b3541 next=null +consecutive overall=0: Key::Up, Key::Down, Key::Right, Key::Left, Key::Insert, Key::ShiftTab, Key::F1, Key::F1, str:61, str:62, null +fallback overall=0 Lone_ESC: Key::Escape next=null fast=true +fallback overall=0 Incomplete_CSI_bracket: str:1b5b next=null fast=true +fallback overall=0 Incomplete_CSI_params: str:1b5b313b next=null fast=true +fallback overall=0 Incomplete_SS3: str:1b4f next=null fast=true +seqTimeout=0 complete: Key::Up, Key::Insert, Key::ShiftTab, Key::F1, Key::F1, str:7a +fallback seqTimeout=0 Lone_ESC: Key::Escape next=null fast=true +fallback seqTimeout=0 Incomplete_CSI_bracket: str:1b5b next=null fast=true +fallback seqTimeout=0 Incomplete_CSI_params: str:1b5b313b next=null fast=true +fallback seqTimeout=0 Incomplete_SS3: str:1b4f next=null fast=true +overall=0,seq>0 complete: Key::Up, Key::F1, str:71 +overall=0,seq>0 Lone_ESC: Key::Escape next=null nonblocking=true +overall=0,seq>0 Incomplete_CSI_bracket: str:1b5b next=null nonblocking=true +overall=0,seq>0 Incomplete_CSI_params: str:1b5b313b next=null nonblocking=true +overall=0,seq>0 Incomplete_SS3: str:1b4f next=null nonblocking=true +overlong CSI: len=32 prefix_ok=true tail=1;A! +malformed+invalid_utf8: str:1b78, str:1b4f58, str:80, str:ff, str:e241 +utf8_retained_zero_timeout: first=null second=str:e282ac +=== Mode: unbuffered === +overall=0 Up: Key::Up next=null +overall=0 Down: Key::Down next=null +overall=0 Right: Key::Right next=null +overall=0 Left: Key::Left next=null +overall=0 Insert: Key::Insert next=null +overall=0 ShiftTab: Key::ShiftTab next=null +overall=0 F1_SS3: Key::F1 next=null +overall=0 F1_CSI: Key::F1 next=null +overall=0 Unsupported_CSI: str:1b5b313b3541 next=null +consecutive overall=0: Key::Up, Key::Down, Key::Right, Key::Left, Key::Insert, Key::ShiftTab, Key::F1, Key::F1, str:61, str:62, null +fallback overall=0 Lone_ESC: Key::Escape next=null fast=true +fallback overall=0 Incomplete_CSI_bracket: str:1b5b next=null fast=true +fallback overall=0 Incomplete_CSI_params: str:1b5b313b next=null fast=true +fallback overall=0 Incomplete_SS3: str:1b4f next=null fast=true +seqTimeout=0 complete: Key::Up, Key::Insert, Key::ShiftTab, Key::F1, Key::F1, str:7a +fallback seqTimeout=0 Lone_ESC: Key::Escape next=null fast=true +fallback seqTimeout=0 Incomplete_CSI_bracket: str:1b5b next=null fast=true +fallback seqTimeout=0 Incomplete_CSI_params: str:1b5b313b next=null fast=true +fallback seqTimeout=0 Incomplete_SS3: str:1b4f next=null fast=true +overall=0,seq>0 complete: Key::Up, Key::F1, str:71 +overall=0,seq>0 Lone_ESC: Key::Escape next=null nonblocking=true +overall=0,seq>0 Incomplete_CSI_bracket: str:1b5b next=null nonblocking=true +overall=0,seq>0 Incomplete_CSI_params: str:1b5b313b next=null nonblocking=true +overall=0,seq>0 Incomplete_SS3: str:1b4f next=null nonblocking=true +overlong CSI: len=32 prefix_ok=true tail=1;A! +malformed+invalid_utf8: str:1b78, str:1b4f58, str:80, str:ff, str:e241 +utf8_retained_zero_timeout: first=null second=str:e282ac +bounded_fuzz_100: PASS diff --git a/ext/standard/tests/terminal/terminal_resize_getsize.phpt b/ext/standard/tests/terminal/terminal_resize_getsize.phpt new file mode 100644 index 000000000000..e839407f6e28 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_resize_getsize.phpt @@ -0,0 +1,165 @@ +--TEST-- +Io\Terminal\Terminal: getSize() does not suppress unreported Key::Resize notifications +--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, +); + +$zero = Duration::fromSeconds(0); + +// 1. Establish initial baseline size A (100x30) via getSize() +fgets($pipes[2]); +$terminal = Terminal::fromStreams($pipes[0], $pipes[1]); +$token = $terminal->enableRawMode(); + +$sA = $terminal->getSize(); +echo "baseline A: {$sA->cols}x{$sA->rows}\n"; + +// Resize to B (120x35), call getSize(), then verify readKey() still reports Key::Resize +fwrite($pipes[0], "next\n"); +fgets($pipes[2]); + +$sB = $terminal->getSize(); +echo "getSize at B: {$sB->cols}x{$sB->rows}\n"; +$kB1 = $terminal->readKey($zero); +echo "readKey after getSize at B: " . ($kB1 === Key::Resize ? 'Key::Resize' : var_export($kB1, true)) . "\n"; + +// 2. No repeated synthetic notification after the change has been reported +$kB2 = $terminal->readKey($zero); +echo "second readKey at B: " . var_export($kB2, true) . "\n"; + +// 3. Repeated getSize() calls before readKey() +fwrite($pipes[0], "next\n"); +fgets($pipes[2]); + +$sC1 = $terminal->getSize(); +$sC2 = $terminal->getSize(); +$sC3 = $terminal->getSize(); +echo "repeated getSize at C: {$sC1->cols}x{$sC1->rows}, {$sC2->cols}x{$sC2->rows}, {$sC3->cols}x{$sC3->rows}\n"; +$kC1 = $terminal->readKey($zero); +$kC2 = $terminal->readKey($zero); +echo "readKey after repeated getSize at C: " . ($kC1 === Key::Resize ? 'Key::Resize' : var_export($kC1, true)) . "\n"; +echo "second readKey at C: " . var_export($kC2, true) . "\n"; + +// 4. Multiple intervening resizes (C -> D -> E) before readKey(), with getSize() observations +fwrite($pipes[0], "next\n"); +fgets($pipes[2]); +$sD = $terminal->getSize(); +echo "intervening D: {$sD->cols}x{$sD->rows}\n"; + +fwrite($pipes[0], "next\n"); +fgets($pipes[2]); +$sE = $terminal->getSize(); +echo "intervening E: {$sE->cols}x{$sE->rows}\n"; + +$kE1 = $terminal->readKey($zero); +$kE2 = $terminal->readKey($zero); +echo "readKey after D->E: " . ($kE1 === Key::Resize ? 'Key::Resize' : var_export($kE1, true)) . "\n"; +echo "second readKey at E: " . var_export($kE2, true) . "\n"; + +// 5. Baseline established by readKey() first, then resize to F + queued escape key + getSize() + readKey() +$terminal2 = Terminal::fromStreams($pipes[0], $pipes[1]); +$kInit = $terminal2->readKey($zero); +echo "terminal2 initial readKey at E: " . var_export($kInit, true) . "\n"; + +fwrite($pipes[0], "next\n"); +fgets($pipes[2]); +usleep(2000); +$sF = $terminal2->getSize(); +echo "terminal2 getSize at F: {$sF->cols}x{$sF->rows}\n"; +$kF1 = $terminal2->readKey($zero); +$kF2 = $terminal2->readKey($zero); +$kF3 = $terminal2->readKey($zero); +echo "terminal2 readKey at F: " . ($kF1 === Key::Resize ? 'Key::Resize' : var_export($kF1, true)) . "\n"; +echo "terminal2 queued key after resize at F: " . ($kF2 === Key::Up ? 'Key::Up' : var_export($kF2, true)) . "\n"; +echo "terminal2 third readKey at F: " . var_export($kF3, true) . "\n"; + +$terminal->restoreMode($token); +fwrite($pipes[0], "done\n"); +unset($token, $terminal, $terminal2); +foreach ($pipes as $pipe) { + if (is_resource($pipe)) fclose($pipe); +} +proc_close($proc); +?> +--EXPECT-- +baseline A: 100x30 +getSize at B: 120x35 +readKey after getSize at B: Key::Resize +second readKey at B: NULL +repeated getSize at C: 110x40, 110x40, 110x40 +readKey after repeated getSize at C: Key::Resize +second readKey at C: NULL +intervening D: 90x25 +intervening E: 130x45 +readKey after D->E: Key::Resize +second readKey at E: NULL +terminal2 initial readKey at E: NULL +terminal2 getSize at F: 105x32 +terminal2 readKey at F: Key::Resize +terminal2 queued key after resize at F: Key::Up +terminal2 third readKey at F: NULL From 03b201ea8540a55c545b502b7e5f88b48e0d178d Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sat, 24 Oct 2026 21:34:20 +0545 Subject: [PATCH 47/52] Fix terminal readKey EOF handling for incomplete UTF-8 --- ext/standard/io_terminal.c | 6 +- .../terminal_alternating_wrappers.phpt | 41 ++-- .../terminal/terminal_key_line_handoff.phpt | 42 ++-- .../terminal/terminal_key_secret_handoff.phpt | 17 +- .../terminal_read_key_truncated_utf8_eof.phpt | 228 ++++++++++++++++++ 5 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_read_key_truncated_utf8_eof.phpt diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 0082734a5ed7..a821379b9ada 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -1940,11 +1940,7 @@ static zend_string *php_io_terminal_finish_utf8_sequence( return NULL; } if (result == PHP_IO_TERMINAL_READ_EOF) { -#ifndef PHP_WIN32 - if (fd >= 0 && php_io_terminal_native_stream_is_tty(fd)) { - zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); - } -#endif + 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) { diff --git a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt b/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt index 8c4ff71cf33b..4132dde5fb81 100644 --- a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt +++ b/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt @@ -11,34 +11,39 @@ if (!class_exists(\Time\Duration::class)) die('skip Time\Duration not available' use Io\Terminal\Terminal; use Time\Duration; -$fp = fopen('php://memory', 'r+'); -// Create two separate Terminal objects wrapping the same memory stream -$term1 = Terminal::fromStreams($fp); -$term2 = Terminal::fromStreams($fp); +[$reader, $writer] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); -// Write partial UTF-8 prefix \xE2 -fwrite($fp, "\xE2"); -rewind($fp); +// Create two separate Terminal objects wrapping the same stream reader +$term1 = Terminal::fromStreams($reader); +$term2 = Terminal::fromStreams($reader); + +// Write partial UTF-8 prefix \xE2 with writer remaining open +fwrite($writer, "\xE2"); // Terminal 1 reads \xE2 and times out; pending UTF-8 buffer belongs to $term1 -$k1 = $term1->readKey(Duration::fromSeconds(0)); +$k1 = $term1->readKey(Duration::fromMilliseconds(20)); var_dump($k1 === null); -// Terminal 2 now attempts to read the stream without $term1's decoder state -fwrite($fp, "\x82\xAC"); -fseek($fp, 1); +// Terminal 2 now attempts to read the shared stream without $term1's decoder state +fwrite($writer, "\x82\xAC"); -// $term2 sees continuation bytes without the initial prefix -$k2 = $term2->readKey(Duration::fromSeconds(0)); +// $term2 consumes \x82 and \xAC from the shared stream without initial prefix +$k2_1 = $term2->readKey(Duration::fromMilliseconds(20)); +$k2_2 = $term2->readKey(Duration::fromMilliseconds(20)); // $term2 cannot assemble the € symbol because it does not share $term1's pending buffer -var_dump($k2 !== "€"); +var_dump($k2_1 !== "€" && $k2_2 !== "€"); -// $term1, given its continuation, assembles the full sequence -fseek($fp, 1); -$k1_res = $term1->readKey(Duration::fromSeconds(0)); +// Write continuation bytes \x82\xAC again so $term1 can complete the sequence +fwrite($writer, "\x82\xAC"); +$k1_res = $term1->readKey(Duration::fromMilliseconds(20)); var_dump($k1_res === "€"); -fclose($fp); +fclose($reader); +fclose($writer); ?> --EXPECT-- diff --git a/ext/standard/tests/terminal/terminal_key_line_handoff.phpt b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt index 57f5be3142b7..e06f3e1c76d8 100644 --- a/ext/standard/tests/terminal/terminal_key_line_handoff.phpt +++ b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt @@ -11,34 +11,46 @@ if (!class_exists(\Time\Duration::class)) die('skip Time\Duration not available' use Io\Terminal\Terminal; use Time\Duration; -function stageLineHandoff(string $continuation): array { - $fp = fopen('php://memory', 'r+'); - fwrite($fp, "\xE2"); - rewind($fp); - $term = Terminal::fromStreams($fp); - $k = $term->readKey(Duration::fromSeconds(0)); - fwrite($fp, $continuation); - fseek($fp, 1); - return [$term, $fp]; +function stageLineHandoff(string $continuation, bool $closeWriter = false): array { + [$read, $write] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 + ); + fwrite($write, "\xE2"); + $term = Terminal::fromStreams($read); + // Writer remains open; readKey times out after 20ms and retains \xE2 + $k = $term->readKey(Duration::fromMilliseconds(20)); + if ($continuation !== "") { + fwrite($write, $continuation); + } + if ($closeWriter) { + fclose($write); + $write = null; + } + return [$term, $read, $write]; } // 1. Complete UTF-8 sequence: \xE2 + \x82\xACline\n => €line -[$t1, $fp1] = stageLineHandoff("\x82\xACline\n"); +[$t1, $read1, $write1] = stageLineHandoff("\x82\xACline\n"); $line1 = $t1->readLine(); var_dump($line1 === "€line"); -fclose($fp1); +fclose($read1); +if ($write1) fclose($write1); // 2. Incomplete UTF-8 at EOF: \xE2 at EOF returns pending byte -[$t2, $fp2] = stageLineHandoff(""); +[$t2, $read2, $write2] = stageLineHandoff("", true); $line2 = $t2->readLine(); var_dump($line2 === "\xE2"); -fclose($fp2); +fclose($read2); +if ($write2) fclose($write2); // 3. Malformed UTF-8: \xE2 followed by non-continuation ASCII -[$t3, $fp3] = stageLineHandoff("Xline\n"); +[$t3, $read3, $write3] = stageLineHandoff("Xline\n"); $line3 = $t3->readLine(); var_dump($line3 === "\xE2Xline"); -fclose($fp3); +fclose($read3); +if ($write3) fclose($write3); ?> --EXPECT-- diff --git a/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt index 4703430b38a3..1d4a8365e1f0 100644 --- a/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt +++ b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt @@ -14,17 +14,20 @@ use Time\Duration; // Helper to stage a stream with \xE2, timeout in readKey, then feed continuation function stageHandoff(string $continuation): Terminal { - $fp = fopen('php://memory', 'r+'); - fwrite($fp, "\xE2"); - rewind($fp); - $term = Terminal::fromStreams($fp); - $k = $term->readKey(Duration::fromSeconds(0)); + [$read, $write] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 + ); + fwrite($write, "\xE2"); + $term = Terminal::fromStreams($read); + // Writer remains open; readKey times out after 20ms and retains \xE2 + $k = $term->readKey(Duration::fromMilliseconds(20)); // Verify \xE2 timed out and is retained if ($k !== null) { echo "Unexpected key: " . bin2hex($k) . "\n"; } - fwrite($fp, $continuation); - fseek($fp, 1); + fwrite($write, $continuation); return $term; } diff --git a/ext/standard/tests/terminal/terminal_read_key_truncated_utf8_eof.phpt b/ext/standard/tests/terminal/terminal_read_key_truncated_utf8_eof.phpt new file mode 100644 index 000000000000..f95e153b674a --- /dev/null +++ b/ext/standard/tests/terminal/terminal_read_key_truncated_utf8_eof.phpt @@ -0,0 +1,228 @@ +--TEST-- +Io\Terminal\Terminal: readKey throws on truncated UTF-8 at EOF and preserves timeouts on open writer +--SKIPIF-- + +--FILE-- +getMessage() === "End of file reached on terminal input stream") { + echo "PASS ($label)\n"; + } else { + echo "FAIL ($label): unexpected message: " . $e->getMessage() . "\n"; + } + } +} + +// 1. Closed sockets: truncated 2-, 3-, and 4-byte prefixes with default, zero, and positive timeouts +$prefixes = [ + '2-byte \xC3' => "\xC3", + '3-byte \xE2' => "\xE2", + '3-byte \xE2\x82' => "\xE2\x82", + '4-byte \xF0' => "\xF0", + '4-byte \xF0\x9F\x92' => "\xF0\x9F\x92", +]; + +$timeouts = [ + 'default' => null, + 'zero' => Duration::fromSeconds(0), + 'positive' => Duration::fromMilliseconds(30), +]; + +foreach ($prefixes as $pname => $prefix) { + foreach ($timeouts as $tname => $timeout) { + [$input, $writer] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 + ); + fwrite($writer, $prefix); + fclose($writer); + + $term = Terminal::fromStreams($input); + for ($i = 1; $i <= 2; $i++) { + assertThrowsEof(function() use ($term, $timeout) { + if ($timeout === null) { + $term->readKey(); + } else { + $term->readKey($timeout); + } + }, "closed socket $pname $tname call $i"); + } + fclose($input); + } +} + +// 2. Anonymous subprocess pipe whose writer exits after emitting incomplete bytes +$proc = proc_open( + [PHP_BINARY, '-r', 'fwrite(STDOUT, "\xE2"); fflush(STDOUT); exit(0);'], + [1 => ['pipe', 'w']], + $pipes +); + +if (!is_resource($proc) || !isset($pipes[1])) { + echo "FAIL (pipe after exit): proc_open failed to spawn child\n"; + if (is_resource($proc)) { + proc_close($proc); + } + if (isset($pipes[1]) && is_resource($pipes[1])) { + fclose($pipes[1]); + } +} else { + $timeoutNs = 5_000_000_000; // 5-second monotonic deadline + $start = hrtime(true); + $timedOut = false; + while (true) { + $status = proc_get_status($proc); + if (!$status['running']) { + break; + } + if ((hrtime(true) - $start) > $timeoutNs) { + $timedOut = true; + break; + } + usleep(5000); + } + + if ($timedOut) { + echo "FAIL (pipe after exit): child process timed out waiting for exit\n"; + fclose($pipes[1]); + proc_terminate($proc); + proc_close($proc); + } else { + $term = Terminal::fromStreams($pipes[1]); + assertThrowsEof(fn() => $term->readKey(Duration::fromMilliseconds(50)), 'pipe after exit call 1'); + assertThrowsEof(fn() => $term->readKey(Duration::fromMilliseconds(50)), 'pipe after exit call 2'); + fclose($pipes[1]); + proc_close($proc); + } +} + +// 3. Finite memory input at EOF +foreach (['default' => null, 'zero' => Duration::fromSeconds(0), 'positive' => Duration::fromMilliseconds(30)] as $tname => $timeout) { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, "\xE2"); + rewind($fp); + + $term = Terminal::fromStreams($fp); + assertThrowsEof(fn() => $timeout === null ? $term->readKey() : $term->readKey($timeout), "memory EOF $tname call 1"); + assertThrowsEof(fn() => $timeout === null ? $term->readKey() : $term->readKey($timeout), "memory EOF $tname call 2"); + fclose($fp); +} + +// 4. Contrasting control: writer remains open, partial character times out, continuation bytes arrive +[$input, $writer] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +fwrite($writer, "\xE2"); +$term = Terminal::fromStreams($input); + +$k1 = $term->readKey(Duration::fromMilliseconds(30)); +var_dump($k1 === null); + +fwrite($writer, "\x82\xAC"); +$k2 = $term->readKey(Duration::fromMilliseconds(50)); +var_dump($k2 === "€"); + +fclose($writer); +fclose($input); + +// 5. Empty EOF control +[$input, $writer] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +fclose($writer); +$term = Terminal::fromStreams($input); +assertThrowsEof(fn() => $term->readKey(), 'empty socket default timeout'); +assertThrowsEof(fn() => $term->readKey(Duration::fromSeconds(0)), 'empty socket zero timeout'); +fclose($input); + +// 6. Complete character followed by EOF control +[$input, $writer] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +fwrite($writer, "A"); +fclose($writer); +$term = Terminal::fromStreams($input); +var_dump($term->readKey(Duration::fromMilliseconds(50)) === "A"); +assertThrowsEof(fn() => $term->readKey(Duration::fromMilliseconds(50)), 'complete char then EOF call 2'); +fclose($input); + +[$input, $writer] = stream_socket_pair( + PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0 +); +fwrite($writer, "€"); +fclose($writer); +$term = Terminal::fromStreams($input); +var_dump($term->readKey(Duration::fromMilliseconds(50)) === "€"); +assertThrowsEof(fn() => $term->readKey(Duration::fromMilliseconds(50)), 'complete multibyte then EOF call 2'); +fclose($input); + +?> +--EXPECT-- +PASS (closed socket 2-byte \xC3 default call 1) +PASS (closed socket 2-byte \xC3 default call 2) +PASS (closed socket 2-byte \xC3 zero call 1) +PASS (closed socket 2-byte \xC3 zero call 2) +PASS (closed socket 2-byte \xC3 positive call 1) +PASS (closed socket 2-byte \xC3 positive call 2) +PASS (closed socket 3-byte \xE2 default call 1) +PASS (closed socket 3-byte \xE2 default call 2) +PASS (closed socket 3-byte \xE2 zero call 1) +PASS (closed socket 3-byte \xE2 zero call 2) +PASS (closed socket 3-byte \xE2 positive call 1) +PASS (closed socket 3-byte \xE2 positive call 2) +PASS (closed socket 3-byte \xE2\x82 default call 1) +PASS (closed socket 3-byte \xE2\x82 default call 2) +PASS (closed socket 3-byte \xE2\x82 zero call 1) +PASS (closed socket 3-byte \xE2\x82 zero call 2) +PASS (closed socket 3-byte \xE2\x82 positive call 1) +PASS (closed socket 3-byte \xE2\x82 positive call 2) +PASS (closed socket 4-byte \xF0 default call 1) +PASS (closed socket 4-byte \xF0 default call 2) +PASS (closed socket 4-byte \xF0 zero call 1) +PASS (closed socket 4-byte \xF0 zero call 2) +PASS (closed socket 4-byte \xF0 positive call 1) +PASS (closed socket 4-byte \xF0 positive call 2) +PASS (closed socket 4-byte \xF0\x9F\x92 default call 1) +PASS (closed socket 4-byte \xF0\x9F\x92 default call 2) +PASS (closed socket 4-byte \xF0\x9F\x92 zero call 1) +PASS (closed socket 4-byte \xF0\x9F\x92 zero call 2) +PASS (closed socket 4-byte \xF0\x9F\x92 positive call 1) +PASS (closed socket 4-byte \xF0\x9F\x92 positive call 2) +PASS (pipe after exit call 1) +PASS (pipe after exit call 2) +PASS (memory EOF default call 1) +PASS (memory EOF default call 2) +PASS (memory EOF zero call 1) +PASS (memory EOF zero call 2) +PASS (memory EOF positive call 1) +PASS (memory EOF positive call 2) +bool(true) +bool(true) +PASS (empty socket default timeout) +PASS (empty socket zero timeout) +bool(true) +PASS (complete char then EOF call 2) +bool(true) +PASS (complete multibyte then EOF call 2) From 2c841c8bf0643759522d4bc621cdaf389f70ce6d Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Fri, 6 Nov 2026 16:23:20 +0545 Subject: [PATCH 48/52] Warn when an Io\Terminal raw-mode token is discarded Mark enableRawMode() with NoDiscard so accidental disposal of the lease is visible. Document token destruction, cover the warning and explicit discard, and remove two setup calls that discarded their tokens. --- ext/standard/io_terminal.stub.php | 3 +++ ext/standard/io_terminal_arginfo.h | 11 ++++++++-- ext/standard/io_terminal_decl.h | 8 ++++---- .../terminal/terminal_raw_mode_nodiscard.phpt | 20 +++++++++++++++++++ .../terminal/terminal_read_key_contracts.phpt | 1 - .../tests/terminal/terminal_resize.phpt | 1 - 6 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 ext/standard/tests/terminal/terminal_raw_mode_nodiscard.phpt diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index ca405fde4140..fd19518c037e 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -52,6 +52,8 @@ public function __construct(int $cols, int $rows) {} } /** + * Destroying the last reference to an active token attempts to release its lease. + * * @strict-properties * @not-serializable */ @@ -81,6 +83,7 @@ public static function fromStreams( public function getSize(): ?TerminalSize {} + #[\NoDiscard(message: "as the returned token must stay alive to keep the raw-mode lease active")] public function enableRawMode(): ModeToken {} public function restoreMode( diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 3b5cdc543f8c..851e74632ebd 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,7 +1,8 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 701a9256c6116cb9161669791188bdf8d7295ba8 + * Stub hash: 4a02ce6dc91286e5038c0fe29f3f280eb64ad97f * Has decl header: yes */ +#include "zend_attributes.h" #include "zend_enum.h" ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_TerminalSize___construct, 0, 0, 2) @@ -71,7 +72,7 @@ static const zend_function_entry class_Io_Terminal_Terminal_methods[] = { 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, enableRawMode, arginfo_class_Io_Terminal_Terminal_enableRawMode, ZEND_ACC_PUBLIC|ZEND_ACC_NODISCARD) 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, readLine, arginfo_class_Io_Terminal_Terminal_readLine, ZEND_ACC_PUBLIC) @@ -191,5 +192,11 @@ static zend_class_entry *register_class_Io_Terminal_Terminal(void) 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_attribute *attribute_NoDiscard_func_enablerawmode_0 = zend_add_function_attribute(zend_hash_str_find_ptr(&class_entry->function_table, "enablerawmode", sizeof("enablerawmode") - 1), ZSTR_KNOWN(ZEND_STR_NODISCARD), 1); + zend_string *attribute_NoDiscard_func_enablerawmode_0_arg0_str = zend_string_init("as the returned token must stay alive to keep the raw-mode lease active", strlen("as the returned token must stay alive to keep the raw-mode lease active"), 1); + ZVAL_STR(&attribute_NoDiscard_func_enablerawmode_0->args[0].value, attribute_NoDiscard_func_enablerawmode_0_arg0_str); + attribute_NoDiscard_func_enablerawmode_0->args[0].name = ZSTR_KNOWN(ZEND_STR_MESSAGE); + return class_entry; } diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index 0cac98609ff9..41487e40a014 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: 701a9256c6116cb9161669791188bdf8d7295ba8 */ + * Stub hash: 4a02ce6dc91286e5038c0fe29f3f280eb64ad97f */ -#ifndef ZEND_IO_TERMINAL_DECL_701a9256c6116cb9161669791188bdf8d7295ba8_H -#define ZEND_IO_TERMINAL_DECL_701a9256c6116cb9161669791188bdf8d7295ba8_H +#ifndef ZEND_IO_TERMINAL_DECL_4a02ce6dc91286e5038c0fe29f3f280eb64ad97f_H +#define ZEND_IO_TERMINAL_DECL_4a02ce6dc91286e5038c0fe29f3f280eb64ad97f_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -35,4 +35,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 28, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_701a9256c6116cb9161669791188bdf8d7295ba8_H */ +#endif /* ZEND_IO_TERMINAL_DECL_4a02ce6dc91286e5038c0fe29f3f280eb64ad97f_H */ diff --git a/ext/standard/tests/terminal/terminal_raw_mode_nodiscard.phpt b/ext/standard/tests/terminal/terminal_raw_mode_nodiscard.phpt new file mode 100644 index 000000000000..fb004c96a1f9 --- /dev/null +++ b/ext/standard/tests/terminal/terminal_raw_mode_nodiscard.phpt @@ -0,0 +1,20 @@ +--TEST-- +Io\Terminal\Terminal::enableRawMode() warns when the lease token is discarded +--FILE-- +enableRawMode(); +(void) $terminal->enableRawMode(); + +$token = $terminal->enableRawMode(); +$terminal->restoreMode($token); +echo "Done\n"; + +?> +--EXPECTF-- +Warning: The return value of method Io\Terminal\Terminal::enableRawMode() should either be used or intentionally ignored by casting it as (void), as the returned token must stay alive to keep the raw-mode lease active in %s on line %d +Done diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt index 1a532f6524b2..9bb2841c11a3 100644 --- a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt +++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt @@ -46,7 +46,6 @@ $proc = proc_open( ); $terminal = Terminal::fromStreams($pipes[0]); -$terminal->enableRawMode(); // Contract 1: No input + zero timeout (non-blocking) returns null immediately $start = hrtime(true); diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt index f0fb31f81cb8..f46052880a86 100644 --- a/ext/standard/tests/terminal/terminal_resize.phpt +++ b/ext/standard/tests/terminal/terminal_resize.phpt @@ -59,7 +59,6 @@ $proc = proc_open( $r1 = fgets($pipes[2]); $terminal = Terminal::fromStreams($pipes[0]); -$terminal->enableRawMode(); $s1 = $terminal->getSize(); echo "s1: {$s1->cols}x{$s1->rows}\n"; From a88859d78917f1acee68e9ac71c4d53180b27da0 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 9 Nov 2026 08:49:23 +0545 Subject: [PATCH 49/52] Rename Io\Terminal\TerminalSize to Dimensions and getSize() to getDimensions() Avoid repeating the Terminal namespace in the size value object and use getDimensions() on Terminal. Also cover separate wrappers reading across a split CRLF secret delimiter. --- ...flectionExtension_getClassNames_basic.phpt | 2 +- ext/standard/io_terminal.c | 16 +++---- ext/standard/io_terminal.stub.php | 4 +- ext/standard/io_terminal_arginfo.h | 20 ++++---- ext/standard/io_terminal_decl.h | 8 ++-- .../terminal_alternating_wrappers.phpt | 16 +++++++ .../tests/terminal/terminal_create.phpt | 24 +++++----- .../terminal/terminal_interface_mock.phpt | 2 +- .../terminal/terminal_raw_mode_non_tty.phpt | 4 +- .../tests/terminal/terminal_resize.phpt | 4 +- .../terminal/terminal_resize_getsize.phpt | 48 +++++++++---------- .../tests/terminal/terminal_size.phpt | 4 +- 12 files changed, 84 insertions(+), 68 deletions(-) diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt index c0b95563215e..f83cb030591e 100644 --- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt +++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt @@ -30,11 +30,11 @@ Io\Poll\InactiveWatcherException Io\Poll\InvalidHandleException Io\Poll\PollException Io\Poll\Watcher +Io\Terminal\Dimensions Io\Terminal\Key Io\Terminal\ModeToken Io\Terminal\Terminal Io\Terminal\TerminalException -Io\Terminal\TerminalSize RoundingMode SortDirection StreamBucket diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index a821379b9ada..49d9f7df2bc3 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -173,7 +173,7 @@ 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_dimensions_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; @@ -2732,8 +2732,8 @@ static zend_string *php_io_terminal_read_stream_secret( } #endif /* !PHP_WIN32 */ -/* Io\Terminal\TerminalSize methods */ -PHP_METHOD(Io_Terminal_TerminalSize, __construct) +/* Io\Terminal\Dimensions methods */ +PHP_METHOD(Io_Terminal_Dimensions, __construct) { zend_long cols, rows; @@ -2752,12 +2752,12 @@ PHP_METHOD(Io_Terminal_TerminalSize, __construct) 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_dimensions_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); + zend_update_property_long(php_io_terminal_dimensions_ce, Z_OBJ_P(ZEND_THIS), "rows", sizeof("rows") - 1, rows); if (UNEXPECTED(EG(exception))) { RETURN_THROWS(); } @@ -2851,7 +2851,7 @@ PHP_METHOD(Io_Terminal_Terminal, fromStreams) } } -PHP_METHOD(Io_Terminal_Terminal, getSize) +PHP_METHOD(Io_Terminal_Terminal, getDimensions) { ZEND_PARSE_PARAMETERS_NONE(); @@ -2878,7 +2878,7 @@ PHP_METHOD(Io_Terminal_Terminal, getSize) 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); + object_init_with_constructor(return_value, php_io_terminal_dimensions_ce, 2, params, NULL); } PHP_METHOD(Io_Terminal_Terminal, enableRawMode) @@ -4096,7 +4096,7 @@ PHP_MINIT_FUNCTION(terminal) php_io_terminal_key_ce = register_class_Io_Terminal_Key(); - php_io_terminal_terminal_size_ce = register_class_Io_Terminal_TerminalSize(); + php_io_terminal_dimensions_ce = register_class_Io_Terminal_Dimensions(); 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; diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index fd19518c037e..f79cd6e3faee 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -43,7 +43,7 @@ enum Key } /** @strict-properties */ - final readonly class TerminalSize + final readonly class Dimensions { public readonly int $cols; public readonly int $rows; @@ -81,7 +81,7 @@ public static function fromStreams( $output = null, ): Terminal {} - public function getSize(): ?TerminalSize {} + public function getDimensions(): ?Dimensions {} #[\NoDiscard(message: "as the returned token must stay alive to keep the raw-mode lease active")] public function enableRawMode(): ModeToken {} diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 851e74632ebd..05b9b3b0c7f2 100644 --- a/ext/standard/io_terminal_arginfo.h +++ b/ext/standard/io_terminal_arginfo.h @@ -1,11 +1,11 @@ /* This is a generated file, edit io_terminal.stub.php instead. - * Stub hash: 4a02ce6dc91286e5038c0fe29f3f280eb64ad97f + * Stub hash: b4b859efdbcba4500483769c7ae8520659cff5fa * Has decl header: yes */ #include "zend_attributes.h" #include "zend_enum.h" -ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_TerminalSize___construct, 0, 0, 2) +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Io_Terminal_Dimensions___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() @@ -23,7 +23,7 @@ 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_INFO_EX(arginfo_class_Io_Terminal_Terminal_getSize, 0, 0, Io\\Terminal\\TerminalSize, 1) +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_getDimensions, 0, 0, Io\\Terminal\\Dimensions, 1) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Io_Terminal_Terminal_enableRawMode, 0, 0, Io\\Terminal\\ModeToken, 0) @@ -45,20 +45,20 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Io_Terminal_Terminal_readS ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, timeout, Time\\Duration, 1, "null") ZEND_END_ARG_INFO() -ZEND_METHOD(Io_Terminal_TerminalSize, __construct); +ZEND_METHOD(Io_Terminal_Dimensions, __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, getDimensions); ZEND_METHOD(Io_Terminal_Terminal, enableRawMode); ZEND_METHOD(Io_Terminal_Terminal, restoreMode); ZEND_METHOD(Io_Terminal_Terminal, readKey); ZEND_METHOD(Io_Terminal_Terminal, readLine); 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_PUBLIC) +static const zend_function_entry class_Io_Terminal_Dimensions_methods[] = { + ZEND_ME(Io_Terminal_Dimensions, __construct, arginfo_class_Io_Terminal_Dimensions___construct, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -71,7 +71,7 @@ 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, getDimensions, arginfo_class_Io_Terminal_Terminal_getDimensions, ZEND_ACC_PUBLIC) ZEND_ME(Io_Terminal_Terminal, enableRawMode, arginfo_class_Io_Terminal_Terminal_enableRawMode, ZEND_ACC_PUBLIC|ZEND_ACC_NODISCARD) 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) @@ -153,11 +153,11 @@ static zend_class_entry *register_class_Io_Terminal_Key(void) return class_entry; } -static zend_class_entry *register_class_Io_Terminal_TerminalSize(void) +static zend_class_entry *register_class_Io_Terminal_Dimensions(void) { zend_class_entry ce, *class_entry; - INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "TerminalSize", class_Io_Terminal_TerminalSize_methods); + INIT_NS_CLASS_ENTRY(ce, "Io\\Terminal", "Dimensions", class_Io_Terminal_Dimensions_methods); 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; diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index 41487e40a014..d0827cc7dd47 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: 4a02ce6dc91286e5038c0fe29f3f280eb64ad97f */ + * Stub hash: b4b859efdbcba4500483769c7ae8520659cff5fa */ -#ifndef ZEND_IO_TERMINAL_DECL_4a02ce6dc91286e5038c0fe29f3f280eb64ad97f_H -#define ZEND_IO_TERMINAL_DECL_4a02ce6dc91286e5038c0fe29f3f280eb64ad97f_H +#ifndef ZEND_IO_TERMINAL_DECL_b4b859efdbcba4500483769c7ae8520659cff5fa_H +#define ZEND_IO_TERMINAL_DECL_b4b859efdbcba4500483769c7ae8520659cff5fa_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -35,4 +35,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 28, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_4a02ce6dc91286e5038c0fe29f3f280eb64ad97f_H */ +#endif /* ZEND_IO_TERMINAL_DECL_b4b859efdbcba4500483769c7ae8520659cff5fa_H */ diff --git a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt b/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt index 4132dde5fb81..ef7721558bfd 100644 --- a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt +++ b/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt @@ -45,8 +45,24 @@ var_dump($k1_res === "€"); fclose($reader); fclose($writer); +// Separate Terminal wrappers also do not share split-CRLF state +$mem = fopen('php://memory', 'w+'); +fwrite($mem, "secret\r\nWidget\r\n"); +rewind($mem); + +$first = Terminal::fromStreams($mem); +$second = Terminal::fromStreams($mem); + +var_dump($first->readSecret() === "secret"); +var_dump($second->readLine() === ""); +var_dump($second->readLine() === "Widget"); +fclose($mem); + ?> --EXPECT-- bool(true) bool(true) bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt index 9ddf03b35ad9..c055319e3d5a 100644 --- a/ext/standard/tests/terminal/terminal_create.phpt +++ b/ext/standard/tests/terminal/terminal_create.phpt @@ -4,7 +4,7 @@ Io\Terminal\Terminal: fromStdio, fromStreams, construction and serialization res cols, $size->rows); foreach ([ @@ -52,23 +52,23 @@ foreach ([ [80, -5], ] as [$cols, $rows]) { try { - new TerminalSize($cols, $rows); + new Dimensions($cols, $rows); } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } } -// Repeated TerminalSize construction must produce only the first error ($cols) +// Repeated Dimensions 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 +// Dimensions serialization $serialized = serialize($size); $unserialized = unserialize($serialized); -var_dump($unserialized instanceof TerminalSize, $unserialized->cols, $unserialized->rows); +var_dump($unserialized instanceof Dimensions, $unserialized->cols, $unserialized->rows); // Clone rejection foreach ([$t1, $t2] as $obj) { @@ -100,11 +100,11 @@ Error: Call to private Io\Terminal\Terminal::__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 -Error: Cannot modify readonly property Io\Terminal\TerminalSize::$cols +ValueError: Io\Terminal\Dimensions::__construct(): Argument #1 ($cols) must be greater than 0 +ValueError: Io\Terminal\Dimensions::__construct(): Argument #2 ($rows) must be greater than 0 +ValueError: Io\Terminal\Dimensions::__construct(): Argument #1 ($cols) must be greater than 0 +ValueError: Io\Terminal\Dimensions::__construct(): Argument #2 ($rows) must be greater than 0 +Error: Cannot modify readonly property Io\Terminal\Dimensions::$cols bool(true) int(80) int(24) diff --git a/ext/standard/tests/terminal/terminal_interface_mock.phpt b/ext/standard/tests/terminal/terminal_interface_mock.phpt index 9097dddbbdc8..47855ee15bd3 100644 --- a/ext/standard/tests/terminal/terminal_interface_mock.phpt +++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt @@ -6,7 +6,7 @@ Io\Terminal\Terminal: final class boundary, dependency injection, and testing wi use Io\Terminal\Key; use Io\Terminal\ModeToken; use Io\Terminal\Terminal; -use Io\Terminal\TerminalSize; +use Io\Terminal\Dimensions; use Time\Duration; // Verify final class design (no interfaces) 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 79b1bd0fcf97..0f7a20d932d5 100644 --- a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt +++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt @@ -13,8 +13,8 @@ $terminal = Terminal::fromStreams($fp); $token = $terminal->enableRawMode(); var_dump($token instanceof ModeToken); -// getSize() continues to return null on non-terminal streams -var_dump($terminal->getSize()); +// getDimensions() continues to return null on non-terminal streams +var_dump($terminal->getDimensions()); // Restoring the token succeeds $terminal->restoreMode($token); diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt index f46052880a86..ae4ea66f86d3 100644 --- a/ext/standard/tests/terminal/terminal_resize.phpt +++ b/ext/standard/tests/terminal/terminal_resize.phpt @@ -60,7 +60,7 @@ $proc = proc_open( $r1 = fgets($pipes[2]); $terminal = Terminal::fromStreams($pipes[0]); -$s1 = $terminal->getSize(); +$s1 = $terminal->getDimensions(); echo "s1: {$s1->cols}x{$s1->rows}\n"; // Signal child to change window size @@ -71,7 +71,7 @@ $r2 = fgets($pipes[2]); $key = $terminal->readKey(Duration::fromSeconds(0)); var_dump($key === Key::Resize); -$s2 = $terminal->getSize(); +$s2 = $terminal->getDimensions(); echo "s2: {$s2->cols}x{$s2->rows}\n"; fwrite($pipes[0], "done\n"); diff --git a/ext/standard/tests/terminal/terminal_resize_getsize.phpt b/ext/standard/tests/terminal/terminal_resize_getsize.phpt index e839407f6e28..29aab6482e8a 100644 --- a/ext/standard/tests/terminal/terminal_resize_getsize.phpt +++ b/ext/standard/tests/terminal/terminal_resize_getsize.phpt @@ -1,5 +1,5 @@ --TEST-- -Io\Terminal\Terminal: getSize() does not suppress unreported Key::Resize notifications +Io\Terminal\Terminal: getDimensions() does not suppress unreported Key::Resize notifications --SKIPIF-- enableRawMode(); -$sA = $terminal->getSize(); +$sA = $terminal->getDimensions(); echo "baseline A: {$sA->cols}x{$sA->rows}\n"; -// Resize to B (120x35), call getSize(), then verify readKey() still reports Key::Resize +// Resize to B (120x35), call getDimensions(), then verify readKey() still reports Key::Resize fwrite($pipes[0], "next\n"); fgets($pipes[2]); -$sB = $terminal->getSize(); -echo "getSize at B: {$sB->cols}x{$sB->rows}\n"; +$sB = $terminal->getDimensions(); +echo "getDimensions at B: {$sB->cols}x{$sB->rows}\n"; $kB1 = $terminal->readKey($zero); -echo "readKey after getSize at B: " . ($kB1 === Key::Resize ? 'Key::Resize' : var_export($kB1, true)) . "\n"; +echo "readKey after getDimensions at B: " . ($kB1 === Key::Resize ? 'Key::Resize' : var_export($kB1, true)) . "\n"; // 2. No repeated synthetic notification after the change has been reported $kB2 = $terminal->readKey($zero); echo "second readKey at B: " . var_export($kB2, true) . "\n"; -// 3. Repeated getSize() calls before readKey() +// 3. Repeated getDimensions() calls before readKey() fwrite($pipes[0], "next\n"); fgets($pipes[2]); -$sC1 = $terminal->getSize(); -$sC2 = $terminal->getSize(); -$sC3 = $terminal->getSize(); -echo "repeated getSize at C: {$sC1->cols}x{$sC1->rows}, {$sC2->cols}x{$sC2->rows}, {$sC3->cols}x{$sC3->rows}\n"; +$sC1 = $terminal->getDimensions(); +$sC2 = $terminal->getDimensions(); +$sC3 = $terminal->getDimensions(); +echo "repeated getDimensions at C: {$sC1->cols}x{$sC1->rows}, {$sC2->cols}x{$sC2->rows}, {$sC3->cols}x{$sC3->rows}\n"; $kC1 = $terminal->readKey($zero); $kC2 = $terminal->readKey($zero); -echo "readKey after repeated getSize at C: " . ($kC1 === Key::Resize ? 'Key::Resize' : var_export($kC1, true)) . "\n"; +echo "readKey after repeated getDimensions at C: " . ($kC1 === Key::Resize ? 'Key::Resize' : var_export($kC1, true)) . "\n"; echo "second readKey at C: " . var_export($kC2, true) . "\n"; -// 4. Multiple intervening resizes (C -> D -> E) before readKey(), with getSize() observations +// 4. Multiple intervening resizes (C -> D -> E) before readKey(), with getDimensions() observations fwrite($pipes[0], "next\n"); fgets($pipes[2]); -$sD = $terminal->getSize(); +$sD = $terminal->getDimensions(); echo "intervening D: {$sD->cols}x{$sD->rows}\n"; fwrite($pipes[0], "next\n"); fgets($pipes[2]); -$sE = $terminal->getSize(); +$sE = $terminal->getDimensions(); echo "intervening E: {$sE->cols}x{$sE->rows}\n"; $kE1 = $terminal->readKey($zero); @@ -121,7 +121,7 @@ $kE2 = $terminal->readKey($zero); echo "readKey after D->E: " . ($kE1 === Key::Resize ? 'Key::Resize' : var_export($kE1, true)) . "\n"; echo "second readKey at E: " . var_export($kE2, true) . "\n"; -// 5. Baseline established by readKey() first, then resize to F + queued escape key + getSize() + readKey() +// 5. Baseline established by readKey() first, then resize to F + queued escape key + getDimensions() + readKey() $terminal2 = Terminal::fromStreams($pipes[0], $pipes[1]); $kInit = $terminal2->readKey($zero); echo "terminal2 initial readKey at E: " . var_export($kInit, true) . "\n"; @@ -129,8 +129,8 @@ echo "terminal2 initial readKey at E: " . var_export($kInit, true) . "\n"; fwrite($pipes[0], "next\n"); fgets($pipes[2]); usleep(2000); -$sF = $terminal2->getSize(); -echo "terminal2 getSize at F: {$sF->cols}x{$sF->rows}\n"; +$sF = $terminal2->getDimensions(); +echo "terminal2 getDimensions at F: {$sF->cols}x{$sF->rows}\n"; $kF1 = $terminal2->readKey($zero); $kF2 = $terminal2->readKey($zero); $kF3 = $terminal2->readKey($zero); @@ -148,18 +148,18 @@ proc_close($proc); ?> --EXPECT-- baseline A: 100x30 -getSize at B: 120x35 -readKey after getSize at B: Key::Resize +getDimensions at B: 120x35 +readKey after getDimensions at B: Key::Resize second readKey at B: NULL -repeated getSize at C: 110x40, 110x40, 110x40 -readKey after repeated getSize at C: Key::Resize +repeated getDimensions at C: 110x40, 110x40, 110x40 +readKey after repeated getDimensions at C: Key::Resize second readKey at C: NULL intervening D: 90x25 intervening E: 130x45 readKey after D->E: Key::Resize second readKey at E: NULL terminal2 initial readKey at E: NULL -terminal2 getSize at F: 105x32 +terminal2 getDimensions at F: 105x32 terminal2 readKey at F: Key::Resize terminal2 queued key after resize at F: Key::Up terminal2 third readKey at F: NULL diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt index 76fb73d038b4..4c7ef3b99b98 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 null for non-terminal streams without environment fallback +Io\Terminal\Terminal: getDimensions returns null for non-terminal streams without environment fallback --ENV-- COLUMNS=100 LINES=30 @@ -11,7 +11,7 @@ use Io\Terminal\Terminal; $fp = fopen('php://temp', 'r+'); $terminal = Terminal::fromStreams($fp); -var_dump($terminal->getSize()); +var_dump($terminal->getDimensions()); ?> --EXPECT-- From c93c75bcc201a515da06f11245a94fff13a2d3bd Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 9 Nov 2026 09:32:00 +0545 Subject: [PATCH 50/52] Address inline review feedback in io_terminal.c and terminal tests --- ext/standard/io_terminal.c | 529 +++++++++--------- ...rappers.phpt => alternating_wrappers.phpt} | 5 - ...trolling_tty.phpt => controlling_tty.phpt} | 0 .../{terminal_create.phpt => create.phpt} | 0 .../{terminal_identity.phpt => identity.phpt} | 0 ...nterface_mock.phpt => interface_mock.phpt} | 0 ...ine_handoff.phpt => key_line_handoff.phpt} | 5 - ...t_handoff.phpt => key_secret_handoff.phpt} | 5 - ...aw_mode.phpt => overlapping_raw_mode.phpt} | 0 .../{terminal_raw_mode.phpt => raw_mode.phpt} | 0 ...n.phpt => raw_mode_failure_injection.phpt} | 0 ..._raw_mode_fork.phpt => raw_mode_fork.phpt} | 0 ..._checks.phpt => raw_mode_isig_checks.phpt} | 0 ...nodiscard.phpt => raw_mode_nodiscard.phpt} | 0 ...ode_non_tty.phpt => raw_mode_non_tty.phpt} | 0 ..._flags.phpt => raw_mode_output_flags.phpt} | 0 .../{terminal_read_key.phpt => read_key.phpt} | 0 ...contracts.phpt => read_key_contracts.phpt} | 0 ...phpt => read_key_ordinary_stream_eof.phpt} | 5 - ...cape.phpt => read_key_partial_escape.phpt} | 0 ...sequences.phpt => read_key_sequences.phpt} | 0 ...key_timeout.phpt => read_key_timeout.phpt} | 0 ....phpt => read_key_truncated_utf8_eof.phpt} | 2 - ..._retain.phpt => read_key_utf8_retain.phpt} | 0 ..._timing.phpt => read_key_utf8_timing.phpt} | 0 ...t => read_key_zero_timeout_sequences.phpt} | 5 - ...terminal_read_line.phpt => read_line.phpt} | 0 ...d_line_lease.phpt => read_line_lease.phpt} | 0 ...ocking.phpt => read_line_nonblocking.phpt} | 0 ...ne_windows.phpt => read_line_windows.phpt} | 0 ...inal_read_secret.phpt => read_secret.phpt} | 0 ..._control.phpt => read_secret_control.phpt} | 0 ..._timeout.phpt => read_secret_timeout.phpt} | 0 .../{terminal_resize.phpt => resize.phpt} | 0 ...esize_getsize.phpt => resize_getsize.phpt} | 0 ..._handoff.phpt => secret_crlf_handoff.phpt} | 4 - .../{terminal_size.phpt => size.phpt} | 0 ...trols.phpt => stream_secret_controls.phpt} | 5 - ...eout.phpt => stream_sequence_timeout.phpt} | 5 - ...tream_timeout.phpt => stream_timeout.phpt} | 5 - 40 files changed, 253 insertions(+), 322 deletions(-) rename ext/standard/tests/terminal/{terminal_alternating_wrappers.phpt => alternating_wrappers.phpt} (90%) rename ext/standard/tests/terminal/{terminal_controlling_tty.phpt => controlling_tty.phpt} (100%) rename ext/standard/tests/terminal/{terminal_create.phpt => create.phpt} (100%) rename ext/standard/tests/terminal/{terminal_identity.phpt => identity.phpt} (100%) rename ext/standard/tests/terminal/{terminal_interface_mock.phpt => interface_mock.phpt} (100%) rename ext/standard/tests/terminal/{terminal_key_line_handoff.phpt => key_line_handoff.phpt} (88%) rename ext/standard/tests/terminal/{terminal_key_secret_handoff.phpt => key_secret_handoff.phpt} (93%) rename ext/standard/tests/terminal/{terminal_overlapping_raw_mode.phpt => overlapping_raw_mode.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode.phpt => raw_mode.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode_failure_injection.phpt => raw_mode_failure_injection.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode_fork.phpt => raw_mode_fork.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode_isig_checks.phpt => raw_mode_isig_checks.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode_nodiscard.phpt => raw_mode_nodiscard.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode_non_tty.phpt => raw_mode_non_tty.phpt} (100%) rename ext/standard/tests/terminal/{terminal_raw_mode_output_flags.phpt => raw_mode_output_flags.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key.phpt => read_key.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_contracts.phpt => read_key_contracts.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_ordinary_stream_eof.phpt => read_key_ordinary_stream_eof.phpt} (96%) rename ext/standard/tests/terminal/{terminal_read_key_partial_escape.phpt => read_key_partial_escape.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_sequences.phpt => read_key_sequences.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_timeout.phpt => read_key_timeout.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_truncated_utf8_eof.phpt => read_key_truncated_utf8_eof.phpt} (97%) rename ext/standard/tests/terminal/{terminal_read_key_utf8_retain.phpt => read_key_utf8_retain.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_utf8_timing.phpt => read_key_utf8_timing.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_key_zero_timeout_sequences.phpt => read_key_zero_timeout_sequences.phpt} (98%) rename ext/standard/tests/terminal/{terminal_read_line.phpt => read_line.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_line_lease.phpt => read_line_lease.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_line_nonblocking.phpt => read_line_nonblocking.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_line_windows.phpt => read_line_windows.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_secret.phpt => read_secret.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_secret_control.phpt => read_secret_control.phpt} (100%) rename ext/standard/tests/terminal/{terminal_read_secret_timeout.phpt => read_secret_timeout.phpt} (100%) rename ext/standard/tests/terminal/{terminal_resize.phpt => resize.phpt} (100%) rename ext/standard/tests/terminal/{terminal_resize_getsize.phpt => resize_getsize.phpt} (100%) rename ext/standard/tests/terminal/{terminal_secret_crlf_handoff.phpt => secret_crlf_handoff.phpt} (97%) rename ext/standard/tests/terminal/{terminal_size.phpt => size.phpt} (100%) rename ext/standard/tests/terminal/{terminal_stream_secret_controls.phpt => stream_secret_controls.phpt} (94%) rename ext/standard/tests/terminal/{terminal_stream_sequence_timeout.phpt => stream_sequence_timeout.phpt} (94%) rename ext/standard/tests/terminal/{terminal_stream_timeout.phpt => stream_timeout.phpt} (94%) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 49d9f7df2bc3..5a9af6290f71 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -101,26 +101,20 @@ typedef struct php_io_terminal_utf8_pending { size_t expected; } php_io_terminal_utf8_pending; -#ifdef PHP_WIN32 typedef struct php_io_terminal_identity { php_io_terminal_native_stream stream; +#ifdef PHP_WIN32 bool is_console; - bool is_non_tty_stream; - zend_ulong stream_res_id; - php_stream *php_stream_ptr; -} 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; +#endif bool is_non_tty_stream; zend_ulong stream_res_id; php_stream *php_stream_ptr; } php_io_terminal_identity; -#endif typedef struct php_io_terminal_shared_mode { php_io_terminal_identity identity; @@ -180,11 +174,13 @@ 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; -#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 +typedef enum php_io_terminal_read_status { + PHP_IO_TERMINAL_READ_EOF = -2, + PHP_IO_TERMINAL_READ_ERROR = -1, + PHP_IO_TERMINAL_READ_TIMEOUT = 0, + PHP_IO_TERMINAL_READ_SUCCESS = 1, + PHP_IO_TERMINAL_READ_RESIZE = 2, +} php_io_terminal_read_status; #if !defined(PHP_WIN32) # if defined(HAVE_PTSNAME_R) || defined(_GNU_SOURCE) || defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__) @@ -214,16 +210,7 @@ static MUTEX_T php_io_terminal_resize_mutex = NULL; 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; + return handler != NULL && handler != SIG_DFL && handler != SIG_IGN; } static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) @@ -369,17 +356,6 @@ static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, p 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->is_non_tty_stream && second->is_non_tty_stream) { - if (first->stream_res_id != 0 && second->stream_res_id != 0) { - return first->stream_res_id == second->stream_res_id; - } - return first->php_stream_ptr == second->php_stream_ptr; - } - return first->is_console && second->is_console && !first->is_non_tty_stream && !second->is_non_tty_stream; -} #else #ifndef PATH_MAX # define PATH_MAX 1024 @@ -464,6 +440,7 @@ static bool php_io_terminal_get_identity(php_io_terminal_native_stream fd, php_i return identity->has_dev || identity->has_sid; } +#endif static bool php_io_terminal_identities_match(const php_io_terminal_identity *first, const php_io_terminal_identity *second) { @@ -478,6 +455,9 @@ static bool php_io_terminal_identities_match(const php_io_terminal_identity *fir return false; } +#ifdef PHP_WIN32 + return first->is_console && second->is_console; +#else if (first->has_dev && second->has_dev) { return first->dev == second->dev; } @@ -487,8 +467,8 @@ static bool php_io_terminal_identities_match(const php_io_terminal_identity *fir } return false; -} #endif +} #ifdef PHP_WIN32 static php_io_terminal_native_stream php_io_terminal_dup_stream(php_io_terminal_native_stream handle) @@ -783,13 +763,13 @@ static zend_object *php_io_terminal_create_object(zend_class_entry *ce) 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; if (php_stream_cast(stream, PHP_STREAM_AS_FD | PHP_STREAM_CAST_INTERNAL, (void **) &descriptor, 0) != SUCCESS) { - return INVALID_HANDLE_VALUE; + return PHP_IO_TERMINAL_INVALID_NATIVE_STREAM; } +#ifdef PHP_WIN32 if (descriptor == INVALID_SOCKET) { return INVALID_HANDLE_VALUE; } @@ -807,12 +787,6 @@ static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stre intptr_t 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; } @@ -838,7 +812,7 @@ static bool php_io_terminal_stream_target_init_ex( target->native_stream = is_input ? STDIN_FILENO : STDOUT_FILENO; #endif if (is_input) { - zval *stdin_zv = zend_get_constant_str("STDIN", sizeof("STDIN") - 1); + zval *stdin_zv = zend_get_constant_str("STDIN", strlen("STDIN")); if (stdin_zv != NULL && Z_TYPE_P(stdin_zv) == IS_RESOURCE) { target->php_stream = (php_stream *) zend_fetch_resource2( Z_RES_P(stdin_zv), @@ -851,7 +825,7 @@ static bool php_io_terminal_stream_target_init_ex( } } } else { - zval *stdout_zv = zend_get_constant_str("STDOUT", sizeof("STDOUT") - 1); + zval *stdout_zv = zend_get_constant_str("STDOUT", strlen("STDOUT")); if (stdout_zv != NULL && Z_TYPE_P(stdout_zv) == IS_RESOURCE) { target->php_stream = (php_stream *) zend_fetch_resource2( Z_RES_P(stdout_zv), @@ -1643,7 +1617,7 @@ static php_poll_ctx *php_io_terminal_create_poll_context(int fd) } #endif -static int php_io_terminal_read_byte( +static php_io_terminal_read_status php_io_terminal_read_byte( php_io_terminal_native_stream fd, php_stream *stream, void *poll_ctx_ptr, @@ -1935,17 +1909,18 @@ static zend_string *php_io_terminal_finish_utf8_sequence( timeout = ¤t_timeout; } - int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); - 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; + php_io_terminal_read_status result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL); + switch (result) { + case PHP_IO_TERMINAL_READ_TIMEOUT: + return NULL; + case PHP_IO_TERMINAL_READ_EOF: + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + return NULL; + case PHP_IO_TERMINAL_READ_SUCCESS: + break; + default: + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; } pending->bytes[pending->length++] = key; @@ -1988,14 +1963,17 @@ static zend_string *php_io_terminal_key_from_escape_sequence( 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); + php_io_terminal_read_status 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 || read_result == PHP_IO_TERMINAL_READ_EOF) { - return ZSTR_INIT_LITERAL("escape", false); - } - 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; + switch (read_result) { + case PHP_IO_TERMINAL_READ_TIMEOUT: + case PHP_IO_TERMINAL_READ_EOF: + return ZSTR_INIT_LITERAL("escape", false); + case PHP_IO_TERMINAL_READ_SUCCESS: + break; + default: + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; } seq_len++; @@ -2010,9 +1988,6 @@ static zend_string *php_io_terminal_key_from_escape_sequence( } 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) { - if (read_result == PHP_IO_TERMINAL_READ_EOF) { - break; - } 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; @@ -2027,36 +2002,40 @@ static zend_string *php_io_terminal_key_from_escape_sequence( } #define PHP_IO_TERMINAL_CSI_IS(literal) \ - (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); - if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return ZSTR_INIT_LITERAL("shifttab", 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[2~")) return ZSTR_INIT_LITERAL("insert", 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); + (memcmp(seq, literal, strlen(literal)) == 0) + + switch (seq_len) { + case 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); + if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return ZSTR_INIT_LITERAL("shifttab", false); + break; + case 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[2~")) return ZSTR_INIT_LITERAL("insert", 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); + break; + case 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); + break; } #undef PHP_IO_TERMINAL_CSI_IS @@ -2069,19 +2048,23 @@ static zend_string *php_io_terminal_key_from_escape_sequence( 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_ERROR) { - zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); - return NULL; + switch (read_result) { + case 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); + } + break; + case PHP_IO_TERMINAL_READ_ERROR: + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + return NULL; + default: + break; } } @@ -2298,35 +2281,37 @@ static zend_string *php_io_terminal_read_stream_key( has_overall_deadline, overall_deadline_ns ); } else { - while (1) { + while (true) { unsigned char key; - int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true, + php_io_terminal_read_status 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); - break; - } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { - if (intern->last_secret_ended_in_cr) { - intern->last_secret_ended_in_cr = false; - if (key == '\n') { - continue; + switch (read_result) { + case PHP_IO_TERMINAL_READ_RESIZE: + result = ZSTR_INIT_LITERAL("resize", false); + break; + case PHP_IO_TERMINAL_READ_SUCCESS: + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (key == '\n') { + continue; + } } - } - result = php_io_terminal_key_from_byte( - fd, stream, poll_ctx, key, sequence_timeout, pending, - has_overall_deadline, overall_deadline_ns - ); - break; - } 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); - break; - } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { - result = NULL; - break; - } 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); - break; + result = php_io_terminal_key_from_byte( + fd, stream, poll_ctx, key, sequence_timeout, pending, + has_overall_deadline, overall_deadline_ns + ); + break; + case PHP_IO_TERMINAL_READ_EOF: + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + break; + case PHP_IO_TERMINAL_READ_TIMEOUT: + result = NULL; + break; + case PHP_IO_TERMINAL_READ_ERROR: + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + break; } + break; } } @@ -2430,7 +2415,7 @@ static zend_string *php_io_terminal_read_stream_secret( php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cur_ts); cur_ts_ptr = &cur_ts; } - int r = php_io_terminal_read_byte(fd, stream, poll_ctx, &next_b, cur_ts_ptr, false, NULL); + php_io_terminal_read_status r = php_io_terminal_read_byte(fd, stream, poll_ctx, &next_b, cur_ts_ptr, false, NULL); if (r == PHP_IO_TERMINAL_READ_TIMEOUT) { *timed_out = true; goto restore; @@ -2476,7 +2461,7 @@ static zend_string *php_io_terminal_read_stream_secret( current_timeout_ptr = ¤t_timeout; } - int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, current_timeout_ptr, false, NULL); + php_io_terminal_read_status 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; @@ -2539,7 +2524,7 @@ static zend_string *php_io_terminal_read_stream_secret( step_timeout_ptr = &step_timeout; } unsigned char next; - int read_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &next, step_timeout_ptr, false, NULL); + php_io_terminal_read_status 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) { /* Standalone Escape cancels */ *timed_out = false; @@ -2681,7 +2666,7 @@ static zend_string *php_io_terminal_read_stream_secret( cont_timeout_ptr = &cont_timeout; } - int cont_res = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[i], cont_timeout_ptr, false, NULL); + php_io_terminal_read_status 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; @@ -2752,12 +2737,12 @@ PHP_METHOD(Io_Terminal_Dimensions, __construct) RETURN_THROWS(); } - zend_update_property_long(php_io_terminal_dimensions_ce, Z_OBJ_P(ZEND_THIS), "cols", sizeof("cols") - 1, cols); + zend_update_property_long(php_io_terminal_dimensions_ce, Z_OBJ_P(ZEND_THIS), "cols", strlen("cols"), cols); if (UNEXPECTED(EG(exception))) { RETURN_THROWS(); } - zend_update_property_long(php_io_terminal_dimensions_ce, Z_OBJ_P(ZEND_THIS), "rows", sizeof("rows") - 1, rows); + zend_update_property_long(php_io_terminal_dimensions_ce, Z_OBJ_P(ZEND_THIS), "rows", strlen("rows"), rows); if (UNEXPECTED(EG(exception))) { RETURN_THROWS(); } @@ -3087,33 +3072,36 @@ PHP_METHOD(Io_Terminal_Terminal, readKey) has_overall_deadline, overall_deadline_ns ); } else { - while (1) { + while (true) { unsigned char byte; - int read_result = php_io_terminal_read_byte( + php_io_terminal_read_status read_result = php_io_terminal_read_byte( stream.native_stream, stream.php_stream, NULL, &byte, win_timeout_ptr, false, NULL ); - if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) { - if (intern->last_secret_ended_in_cr) { - intern->last_secret_ended_in_cr = false; - if (byte == '\n') { - continue; + switch (read_result) { + case PHP_IO_TERMINAL_READ_SUCCESS: + if (intern->last_secret_ended_in_cr) { + intern->last_secret_ended_in_cr = false; + if (byte == '\n') { + continue; + } } - } - key = php_io_terminal_key_from_byte( - stream.native_stream, stream.php_stream, NULL, byte, &win_seq_timeout, &intern->pending_utf8, - has_overall_deadline, overall_deadline_ns - ); - break; - } 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); - break; - } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { - key = NULL; - break; - } 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); - break; + key = php_io_terminal_key_from_byte( + stream.native_stream, stream.php_stream, NULL, byte, &win_seq_timeout, &intern->pending_utf8, + has_overall_deadline, overall_deadline_ns + ); + break; + case PHP_IO_TERMINAL_READ_EOF: + zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0); + break; + case PHP_IO_TERMINAL_READ_TIMEOUT: + key = NULL; + break; + case PHP_IO_TERMINAL_READ_ERROR: + default: + zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0); + break; } + break; } } } @@ -3635,7 +3623,6 @@ PHP_METHOD(Io_Terminal_Terminal, readLine) } } -#ifdef PHP_WIN32 if (intern->last_secret_ended_in_cr) { intern->last_secret_ended_in_cr = false; if (stream.php_stream != NULL) { @@ -3652,6 +3639,7 @@ PHP_METHOD(Io_Terminal_Terminal, readLine) } } +#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) { @@ -3682,22 +3670,6 @@ PHP_METHOD(Io_Terminal_Terminal, readLine) } RETURN_STR(line); #else - if (intern->last_secret_ended_in_cr) { - intern->last_secret_ended_in_cr = false; - if (stream.php_stream != NULL) { - if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { - if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { - stream.php_stream->readpos++; - } - } else { - int ch = php_stream_getc(stream.php_stream); - if (ch != '\n' && ch != EOF && intern->pending_utf8.length < sizeof(intern->pending_utf8.bytes)) { - intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch; - } - } - } - } - 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); @@ -3850,7 +3822,7 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_ts); cur_ts_ptr = &cur_ts; } - int r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, cur_ts_ptr, false, NULL); + php_io_terminal_read_status r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, cur_ts_ptr, false, NULL); if (r == PHP_IO_TERMINAL_READ_TIMEOUT) { timed_out = true; goto win_secret_done; @@ -3894,7 +3866,7 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) } cur_to_ptr = &cur_to; } - int read_result = php_io_terminal_read_byte( + php_io_terminal_read_status read_result = php_io_terminal_read_byte( stream.native_stream, stream.php_stream, NULL, &byte, cur_to_ptr, false, NULL ); if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) { @@ -3914,57 +3886,100 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) } } - if (byte == '\r' || byte == '\n') { - if (byte == '\r') { - if (stream.php_stream != NULL && stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { - if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { - stream.php_stream->readpos++; + switch (byte) { + case '\r': + case '\n': + if (byte == '\r') { + if (stream.php_stream != NULL && stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) { + if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') { + stream.php_stream->readpos++; + } + } else { + intern->last_secret_ended_in_cr = true; } - } else { - intern->last_secret_ended_in_cr = true; - } - } - success = true; - break; - } - if (byte == 0x7f || byte == 0x08) { - php_io_terminal_buffer_remove_last_utf8_char(&sec_str); - continue; - } - if (byte == 0x03 || byte == 0x04) { - timed_out = false; - break; - } - if (byte == 0x1b) { - struct timespec esc_to; - if (win_has_deadline) { - zend_hrtime_t now = zend_hrtime(); - if (now >= win_deadline_ns) { - esc_to.tv_sec = 0; - esc_to.tv_nsec = 0; - } else { - zend_hrtime_t rem_ns = win_deadline_ns - now; - zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; - php_io_terminal_ns_to_timespec(step_ns, &esc_to); } - } else { - esc_to.tv_sec = 0; - esc_to.tv_nsec = 25000000L; - } - - unsigned char next_b; - int rnext = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, &esc_to, false, NULL); - if (rnext != PHP_IO_TERMINAL_READ_SUCCESS) { - /* Standalone Escape cancels */ - timed_out = false; + success = true; goto win_secret_done; - } - if (next_b == 0x03 || next_b == 0x04 || next_b == 0x1b || next_b == '\r' || next_b == '\n') { + case 0x7f: + case 0x08: + php_io_terminal_buffer_remove_last_utf8_char(&sec_str); + break; + case 0x03: + case 0x04: timed_out = false; goto win_secret_done; - } - if (next_b == '[') { - while (true) { + case 0x1b: + { + struct timespec esc_to; + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char next_b; + php_io_terminal_read_status rnext = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, &esc_to, false, NULL); + if (rnext != PHP_IO_TERMINAL_READ_SUCCESS) { + /* Standalone Escape cancels */ + timed_out = false; + goto win_secret_done; + } + if (next_b == 0x03 || next_b == 0x04 || next_b == 0x1b || next_b == '\r' || next_b == '\n') { + timed_out = false; + goto win_secret_done; + } + if (next_b == '[') { + while (true) { + if (win_has_deadline) { + zend_hrtime_t now = zend_hrtime(); + if (now >= win_deadline_ns) { + if (win_is_positive_timeout) { + timed_out = true; + goto win_secret_done; + } + esc_to.tv_sec = 0; + esc_to.tv_nsec = 0; + } else { + zend_hrtime_t rem_ns = win_deadline_ns - now; + zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + php_io_terminal_ns_to_timespec(step_ns, &esc_to); + } + } else { + esc_to.tv_sec = 0; + esc_to.tv_nsec = 25000000L; + } + + unsigned char csi_ch; + php_io_terminal_read_status csi_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &csi_ch, &esc_to, false, NULL); + if (csi_r != PHP_IO_TERMINAL_READ_SUCCESS) { + if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + break; + } + if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) { + timed_out = true; + goto win_secret_done; + } + if (csi_ch == 0x03 || csi_ch == 0x04 || csi_ch == 0x1b || csi_ch == '\r' || csi_ch == '\n') { + timed_out = false; + goto win_secret_done; + } + if (csi_ch >= 0x40 && csi_ch <= 0x7e) break; + } + break; + } + if (next_b == 'O') { if (win_has_deadline) { zend_hrtime_t now = zend_hrtime(); if (now >= win_deadline_ns) { @@ -3984,9 +3999,9 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) esc_to.tv_nsec = 25000000L; } - unsigned char csi_ch; - int csi_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &csi_ch, &esc_to, false, NULL); - if (csi_r != PHP_IO_TERMINAL_READ_SUCCESS) { + unsigned char ss3_ch; + php_io_terminal_read_status ss3_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &ss3_ch, &esc_to, false, NULL); + if (ss3_r != PHP_IO_TERMINAL_READ_SUCCESS) { if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { timed_out = true; goto win_secret_done; @@ -3997,56 +4012,18 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) timed_out = true; goto win_secret_done; } - if (csi_ch == 0x03 || csi_ch == 0x04 || csi_ch == 0x1b || csi_ch == '\r' || csi_ch == '\n') { + if (ss3_ch == 0x03 || ss3_ch == 0x04 || ss3_ch == 0x1b || ss3_ch == '\r' || ss3_ch == '\n') { timed_out = false; goto win_secret_done; } - if (csi_ch >= 0x40 && csi_ch <= 0x7e) break; - } - continue; - } - if (next_b == 'O') { - if (win_has_deadline) { - zend_hrtime_t now = zend_hrtime(); - if (now >= win_deadline_ns) { - if (win_is_positive_timeout) { - timed_out = true; - goto win_secret_done; - } - esc_to.tv_sec = 0; - esc_to.tv_nsec = 0; - } else { - zend_hrtime_t rem_ns = win_deadline_ns - now; - zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; - php_io_terminal_ns_to_timespec(step_ns, &esc_to); - } - } else { - esc_to.tv_sec = 0; - esc_to.tv_nsec = 25000000L; - } - - unsigned char ss3_ch; - int ss3_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &ss3_ch, &esc_to, false, NULL); - if (ss3_r != PHP_IO_TERMINAL_READ_SUCCESS) { - if (win_has_deadline && zend_hrtime() >= win_deadline_ns) { - timed_out = true; - goto win_secret_done; - } - continue; - } - if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) { - timed_out = true; - goto win_secret_done; - } - if (ss3_ch == 0x03 || ss3_ch == 0x04 || ss3_ch == 0x1b || ss3_ch == '\r' || ss3_ch == '\n') { - timed_out = false; - goto win_secret_done; + break; } - continue; + break; } - continue; + default: + smart_str_appendc(&sec_str, (char) byte); + break; } - smart_str_appendc(&sec_str, (char) byte); } win_secret_done: @@ -4087,7 +4064,7 @@ PHP_MINIT_FUNCTION(terminal) zend_class_entry *io_exception_ce = zend_hash_str_find_ptr( CG(class_table), "io\\ioexception", - sizeof("io\\ioexception") - 1 + strlen("io\\ioexception") ); ZEND_ASSERT(io_exception_ce != NULL); diff --git a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt b/ext/standard/tests/terminal/alternating_wrappers.phpt similarity index 90% rename from ext/standard/tests/terminal/terminal_alternating_wrappers.phpt rename to ext/standard/tests/terminal/alternating_wrappers.phpt index ef7721558bfd..874fe9da9e52 100644 --- a/ext/standard/tests/terminal/terminal_alternating_wrappers.phpt +++ b/ext/standard/tests/terminal/alternating_wrappers.phpt @@ -1,10 +1,5 @@ --TEST-- Io\Terminal\Terminal: separate Terminal wrappers own separate decoder state ---SKIPIF-- - --FILE-- --FILE-- --FILE-- --FILE-- --FILE-- diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt b/ext/standard/tests/terminal/read_key_utf8_retain.phpt similarity index 100% rename from ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt rename to ext/standard/tests/terminal/read_key_utf8_retain.phpt diff --git a/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt b/ext/standard/tests/terminal/read_key_utf8_timing.phpt similarity index 100% rename from ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt rename to ext/standard/tests/terminal/read_key_utf8_timing.phpt diff --git a/ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt b/ext/standard/tests/terminal/read_key_zero_timeout_sequences.phpt similarity index 98% rename from ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt rename to ext/standard/tests/terminal/read_key_zero_timeout_sequences.phpt index 65b299cebb45..44611705bbb4 100644 --- a/ext/standard/tests/terminal/terminal_read_key_zero_timeout_sequences.phpt +++ b/ext/standard/tests/terminal/read_key_zero_timeout_sequences.phpt @@ -1,10 +1,5 @@ --TEST-- Io\Terminal\Terminal: readKey zero-timeout and sequenceTimeout zero escape sequence decoding ---SKIPIF-- - --FILE-- --FILE-- --FILE-- --FILE-- --FILE-- Date: Mon, 9 Nov 2026 09:38:17 +0545 Subject: [PATCH 51/52] Fix logical-op warning in php_io_terminal_is_callable_handler --- ext/standard/io_terminal.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 5a9af6290f71..3f7d6ae4e336 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -210,7 +210,7 @@ static MUTEX_T php_io_terminal_resize_mutex = NULL; static zend_always_inline bool php_io_terminal_is_callable_handler(void (*handler)(int)) { - return handler != NULL && handler != SIG_DFL && handler != SIG_IGN; + return handler != SIG_DFL && handler != SIG_IGN; } static void php_io_terminal_sigwinch_handler(int signo, siginfo_t *info, void *context) From f603bbbc5105c6d5fa962966e717c084a4249c3a Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Mon, 9 Nov 2026 10:26:58 +0545 Subject: [PATCH 52/52] Support sequenceTimeout parameter in Terminal::readSecret() --- ext/standard/io_terminal.c | 70 ++++++++++++++------ ext/standard/io_terminal.stub.php | 1 + ext/standard/io_terminal_arginfo.h | 3 +- ext/standard/io_terminal_decl.h | 8 +-- ext/standard/tests/terminal/read_secret.phpt | 15 ++++- 5 files changed, 69 insertions(+), 28 deletions(-) diff --git a/ext/standard/io_terminal.c b/ext/standard/io_terminal.c index 3f7d6ae4e336..4225e605f97d 100644 --- a/ext/standard/io_terminal.c +++ b/ext/standard/io_terminal.c @@ -2350,6 +2350,7 @@ static zend_string *php_io_terminal_read_stream_secret( php_io_terminal_native_stream input, php_stream *stream, const struct timespec *timeout, + const struct timespec *sequence_timeout, php_io_terminal_object *intern, bool *timed_out ) @@ -2393,6 +2394,8 @@ static zend_string *php_io_terminal_read_stream_secret( is_positive_timeout = (timeout->tv_sec > 0 || timeout->tv_nsec > 0); } + zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout); + smart_str secret = {0}; bool success = false; unsigned char key = 0; @@ -2514,13 +2517,12 @@ static zend_string *php_io_terminal_read_stream_secret( 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; + zend_hrtime_t step_ns = remaining_ns < seq_timeout_ns ? remaining_ns : seq_timeout_ns; 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 = *sequence_timeout; step_timeout_ptr = &step_timeout; } unsigned char next; @@ -2553,13 +2555,12 @@ static zend_string *php_io_terminal_read_stream_secret( 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; + zend_hrtime_t step_ns = remaining_ns < seq_timeout_ns ? remaining_ns : seq_timeout_ns; 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 = *sequence_timeout; step_timeout_ptr = &step_timeout; } @@ -2603,13 +2604,12 @@ static zend_string *php_io_terminal_read_stream_secret( 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; + zend_hrtime_t step_ns = remaining_ns < seq_timeout_ns ? remaining_ns : seq_timeout_ns; 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 = *sequence_timeout; step_timeout_ptr = &step_timeout; } @@ -3731,10 +3731,12 @@ PHP_METHOD(Io_Terminal_Terminal, readLine) PHP_METHOD(Io_Terminal_Terminal, readSecret) { php_date_time_duration *timeout_duration = NULL; + php_date_time_duration *seq_timeout_duration = NULL; - ZEND_PARSE_PARAMETERS_START(0, 1) + 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) { @@ -3742,6 +3744,11 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) RETURN_THROWS(); } + if (seq_timeout_duration != NULL && seq_timeout_duration->duration.negative) { + zend_argument_value_error(2, "must not be negative"); + RETURN_THROWS(); + } + php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS); php_io_terminal_stream_target stream; @@ -3787,12 +3794,15 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) bool success = false; unsigned char byte = 0; struct timespec win_sec_timeout; + struct timespec win_seq_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L}; const struct timespec *win_sec_timeout_ptr = NULL; bool win_has_deadline = false; zend_hrtime_t win_deadline_ns = 0; bool win_is_positive_timeout = false; + bool win_is_non_blocking = false; if (timeout_duration != NULL) { + win_is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); win_sec_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); win_sec_timeout.tv_nsec = timeout_duration->duration.nanoseconds; win_sec_timeout_ptr = &win_sec_timeout; @@ -3804,6 +3814,16 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) win_is_positive_timeout = (win_sec_timeout.tv_sec > 0 || win_sec_timeout.tv_nsec > 0); } + if (win_is_non_blocking) { + win_seq_timeout.tv_sec = 0; + win_seq_timeout.tv_nsec = 0; + } else if (seq_timeout_duration != NULL) { + win_seq_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds); + win_seq_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds; + } + + zend_hrtime_t win_seq_timeout_ns = php_io_terminal_timespec_to_ns(&win_seq_timeout); + /* If pending UTF-8 bytes exist from previous readKey, process them first */ if (intern->pending_utf8.length > 0) { unsigned char terminating_key = 0; @@ -3918,12 +3938,11 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) esc_to.tv_nsec = 0; } else { zend_hrtime_t rem_ns = win_deadline_ns - now; - zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + zend_hrtime_t step_ns = rem_ns < win_seq_timeout_ns ? rem_ns : win_seq_timeout_ns; php_io_terminal_ns_to_timespec(step_ns, &esc_to); } } else { - esc_to.tv_sec = 0; - esc_to.tv_nsec = 25000000L; + esc_to = win_seq_timeout; } unsigned char next_b; @@ -3950,12 +3969,11 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) esc_to.tv_nsec = 0; } else { zend_hrtime_t rem_ns = win_deadline_ns - now; - zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + zend_hrtime_t step_ns = rem_ns < win_seq_timeout_ns ? rem_ns : win_seq_timeout_ns; php_io_terminal_ns_to_timespec(step_ns, &esc_to); } } else { - esc_to.tv_sec = 0; - esc_to.tv_nsec = 25000000L; + esc_to = win_seq_timeout; } unsigned char csi_ch; @@ -3991,12 +4009,11 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) esc_to.tv_nsec = 0; } else { zend_hrtime_t rem_ns = win_deadline_ns - now; - zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL; + zend_hrtime_t step_ns = rem_ns < win_seq_timeout_ns ? rem_ns : win_seq_timeout_ns; php_io_terminal_ns_to_timespec(step_ns, &esc_to); } } else { - esc_to.tv_sec = 0; - esc_to.tv_nsec = 25000000L; + esc_to = win_seq_timeout; } unsigned char ss3_ch; @@ -4035,14 +4052,27 @@ PHP_METHOD(Io_Terminal_Terminal, readSecret) } #else 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) { + is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0); timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds); timeout.tv_nsec = timeout_duration->duration.nanoseconds; timeout_ptr = &timeout; } + + 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 *secret = php_io_terminal_read_stream_secret( - stream.native_stream, stream.php_stream, timeout_ptr, intern, &timed_out + stream.native_stream, stream.php_stream, timeout_ptr, &sequence_timeout, intern, &timed_out ); #endif if (secret != NULL) { diff --git a/ext/standard/io_terminal.stub.php b/ext/standard/io_terminal.stub.php index f79cd6e3faee..c3cc09278280 100644 --- a/ext/standard/io_terminal.stub.php +++ b/ext/standard/io_terminal.stub.php @@ -99,6 +99,7 @@ public function readLine(): ?string {} public function readSecret( ?\Time\Duration $timeout = null, + ?\Time\Duration $sequenceTimeout = null, ): ?string {} } } diff --git a/ext/standard/io_terminal_arginfo.h b/ext/standard/io_terminal_arginfo.h index 05b9b3b0c7f2..594a37435bae 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: b4b859efdbcba4500483769c7ae8520659cff5fa + * Stub hash: e24d70debb197766b78c9a58b9d5300f6ced77a5 * Has decl header: yes */ #include "zend_attributes.h" @@ -43,6 +43,7 @@ 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_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, sequenceTimeout, Time\\Duration, 1, "null") ZEND_END_ARG_INFO() ZEND_METHOD(Io_Terminal_Dimensions, __construct); diff --git a/ext/standard/io_terminal_decl.h b/ext/standard/io_terminal_decl.h index d0827cc7dd47..48f199ff0286 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: b4b859efdbcba4500483769c7ae8520659cff5fa */ + * Stub hash: e24d70debb197766b78c9a58b9d5300f6ced77a5 */ -#ifndef ZEND_IO_TERMINAL_DECL_b4b859efdbcba4500483769c7ae8520659cff5fa_H -#define ZEND_IO_TERMINAL_DECL_b4b859efdbcba4500483769c7ae8520659cff5fa_H +#ifndef ZEND_IO_TERMINAL_DECL_e24d70debb197766b78c9a58b9d5300f6ced77a5_H +#define ZEND_IO_TERMINAL_DECL_e24d70debb197766b78c9a58b9d5300f6ced77a5_H typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_Up = 1, @@ -35,4 +35,4 @@ typedef enum zend_enum_Io_Terminal_Key { ZEND_ENUM_Io_Terminal_Key_F12 = 28, } zend_enum_Io_Terminal_Key; -#endif /* ZEND_IO_TERMINAL_DECL_b4b859efdbcba4500483769c7ae8520659cff5fa_H */ +#endif /* ZEND_IO_TERMINAL_DECL_e24d70debb197766b78c9a58b9d5300f6ced77a5_H */ diff --git a/ext/standard/tests/terminal/read_secret.phpt b/ext/standard/tests/terminal/read_secret.phpt index 14b28590608a..2153bc23b7fa 100644 --- a/ext/standard/tests/terminal/read_secret.phpt +++ b/ext/standard/tests/terminal/read_secret.phpt @@ -30,8 +30,8 @@ try { // readSecret rejects extra arguments try { - $nonTty->readSecret(null, null); - echo "FAIL: readSecret accepted multiple arguments\n"; + $nonTty->readSecret(null, null, null); + echo "FAIL: readSecret accepted extra arguments\n"; } catch (Throwable $e) { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } @@ -44,6 +44,14 @@ try { echo $e::class, ": ", $e->getMessage(), PHP_EOL; } +// readSecret rejects negative sequenceTimeout +try { + $nonTty->readSecret(null, Duration::fromSeconds(1)->negate()); + echo "FAIL: readSecret accepted negative sequenceTimeout\n"; +} catch (Throwable $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + fclose($fp); ?> @@ -51,5 +59,6 @@ fclose($fp); bool(true) Io\Terminal\TerminalException: Unable to read secret from terminal 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 +ArgumentCountError: Io\Terminal\Terminal::readSecret() expects at most 2 arguments, 3 given ValueError: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must not be negative +ValueError: Io\Terminal\Terminal::readSecret(): Argument #2 ($sequenceTimeout) must not be negative