From b97762279f81fd438cbe3f6b9775bbcb35344a60 Mon Sep 17 00:00:00 2001 From: Pratik Bhujel Date: Sat, 26 Sep 2026 21:09:49 +0545 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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);