diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
index c497855f7347..0259162e7def 100644
--- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
+++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
@@ -30,6 +30,13 @@ Io\Poll\InactiveWatcherException
Io\Poll\InvalidHandleException
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
RoundingMode
SortDirection
StreamBucket
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..34a8793c05ef
--- /dev/null
+++ b/ext/standard/io_terminal.c
@@ -0,0 +1,3243 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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_poll.h"
+#include "php_streams.h"
+#include "ext/standard/basic_functions.h"
+#include "ext/date/php_time.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
+#endif
+
+#define PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS 25
+
+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;
+}
+
+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)
+
+static inline zend_hrtime_t php_io_terminal_timespec_to_ns(const struct timespec *ts)
+{
+ if (ts == NULL) {
+ return 0;
+ }
+ 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)
+{
+ 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;
+ 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;
+} 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_shared_mode {
+ php_io_terminal_identity identity;
+ php_io_terminal_native_stream restore_stream;
+#ifdef PHP_WIN32
+ DWORD saved_mode;
+#else
+ struct termios saved_mode;
+#endif
+ 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_shared_mode *shared;
+ bool valid;
+ 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;
+ bool has_last_size;
+#ifdef PHP_WIN32
+ INPUT_RECORD pending_key;
+ WCHAR pending_key_high_surrogate;
+ WCHAR pending_high_surrogate;
+#else
+ php_io_terminal_utf8_pending pending_utf8;
+#endif
+ zend_object std;
+} php_io_terminal_object;
+
+typedef struct php_io_terminal_stream_target {
+ php_io_terminal_native_stream native_stream;
+ php_stream *php_stream;
+ zval *stream_resource;
+ 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;
+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;
+
+#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
+# 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)
+#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;
+static struct sigaction php_io_terminal_previous_resize_action;
+# ifdef ZTS
+static MUTEX_T php_io_terminal_resize_mutex = NULL;
+# 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))
+{
+ 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;
+
+ 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_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 {
+ php_io_terminal_previous_resize_action.sa_handler(signo);
+ }
+ }
+}
+
+static bool php_io_terminal_install_resize_handler(sig_atomic_t *generation)
+{
+ struct sigaction action;
+ bool installed = true;
+ sig_atomic_t current_generation;
+
+ PHP_IO_TERMINAL_RESIZE_LOCK();
+ current_generation = php_io_terminal_resize_generation;
+
+ if (php_io_terminal_resize_readers == 0) {
+ memset(&action, 0, sizeof(action));
+ 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;
+ }
+
+ if (installed) {
+ php_io_terminal_resize_readers++;
+ *generation = current_generation;
+ }
+
+ PHP_IO_TERMINAL_RESIZE_UNLOCK();
+
+ return installed;
+}
+
+static void php_io_terminal_restore_resize_handler(void)
+{
+ 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();
+}
+#endif /* SIGWINCH */
+#endif /* !PHP_WIN32 */
+
+#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 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 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
+ return stream != INVALID_HANDLE_VALUE && stream != NULL;
+#else
+ return stream >= 0;
+#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)
+{
+ identity->stream = handle;
+ identity->is_console = false;
+ if (!php_io_terminal_native_stream_is_valid(handle)) {
+ return false;
+ }
+ DWORD 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)
+{
+ return first->is_console && second->is_console;
+}
+#else
+#ifndef PATH_MAX
+# define PATH_MAX 1024
+#endif
+
+static bool php_io_terminal_get_pty_peer_dev(int fd, dev_t *peer_dev)
+{
+ char peer_path[PATH_MAX];
+ bool got_peer_name = false;
+
+#if defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R)
+ 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(peer_path)) {
+ memcpy(peer_path, pts, len + 1);
+ got_peer_name = true;
+ }
+ }
+ PHP_IO_TERMINAL_PTSNAME_UNLOCK();
+#endif
+
+ if (got_peer_name && peer_path[0] != '\0') {
+ struct stat st;
+ if (stat(peer_path, &st) == 0 && S_ISCHR(st.st_mode) && st.st_rdev != 0) {
+ *peer_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 peer_dev = 0;
+ if (php_io_terminal_get_pty_peer_dev(fd, &peer_dev)) {
+ identity->dev = peer_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->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
+
+#ifdef PHP_WIN32
+static php_io_terminal_native_stream php_io_terminal_dup_stream(php_io_terminal_native_stream handle)
+{
+ HANDLE current_process = GetCurrentProcess();
+ HANDLE new_handle = INVALID_HANDLE_VALUE;
+
+ if (DuplicateHandle(current_process, handle, current_process, &new_handle, 0, FALSE, DUPLICATE_SAME_ACCESS)) {
+ return new_handle;
+ }
+
+ new_handle = CreateFileW(
+ L"CONIN$",
+ GENERIC_READ | GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ NULL,
+ OPEN_EXISTING,
+ 0,
+ NULL
+ );
+
+ if (new_handle != INVALID_HANDLE_VALUE) {
+ return new_handle;
+ }
+
+ return INVALID_HANDLE_VALUE;
+}
+
+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;
+
+#if defined(F_DUPFD_CLOEXEC)
+ new_fd = fcntl(fd, F_DUPFD_CLOEXEC, 0);
+ if (new_fd >= 0) {
+ return new_fd;
+ }
+#endif
+
+ 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;
+ }
+
+ return -1;
+}
+
+static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream)
+{
+ if (stream >= 0) {
+ close(stream);
+ }
+}
+#endif
+
+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_shared_mode(const php_io_terminal_shared_mode *shared)
+{
+ if (!php_io_terminal_native_stream_is_valid(shared->restore_stream)) {
+ return false;
+ }
+
+#ifdef PHP_WIN32
+ 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;
+ }
+#else
+ 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;
+ }
+ }
+#endif
+ 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;
+
+ if (!token_obj->valid || shared == NULL) {
+ if (error_msg != NULL) {
+ *error_msg = "Terminal mode token is not active";
+ }
+ return false;
+ }
+
+ if (shared->lease_count > 1) {
+ shared->lease_count--;
+ token_obj->valid = false;
+ token_obj->shared = NULL;
+ return true;
+ }
+
+ if (!php_io_terminal_restore_shared_mode(shared)) {
+ if (error_msg != NULL) {
+ *error_msg = "Failed to restore terminal mode";
+ }
+ return false;
+ }
+
+ token_obj->valid = false;
+ token_obj->shared = NULL;
+
+ 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 && intern->shared != NULL) {
+ if (!php_io_terminal_release_token_lease(intern, NULL)) {
+ intern->valid = false;
+ intern->shared = NULL;
+ }
+ }
+
+ zend_object_std_dtor(&intern->std);
+}
+
+static zend_object *php_io_terminal_mode_token_create_object(zend_class_entry *ce)
+{
+ php_io_terminal_mode_token_object *intern = zend_object_alloc(sizeof(*intern), ce);
+
+ intern->shared = NULL;
+ intern->valid = false;
+
+ zend_object_std_init(&intern->std, ce);
+ object_properties_init(&intern->std, ce);
+ intern->std.handlers = &php_io_terminal_mode_token_handlers;
+
+ return &intern->std;
+}
+
+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_system_mode_token_ce);
+ intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value);
+ intern->shared = shared;
+ intern->valid = true;
+}
+
+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);
+
+ zend_object_std_dtor(&intern->std);
+}
+
+static zend_object *php_io_terminal_create_object(zend_class_entry *ce)
+{
+ php_io_terminal_object *intern = zend_object_alloc(sizeof(*intern), 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;
+ 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 = &php_io_terminal_object_handlers;
+
+ 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) {
+ 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 php_io_terminal_stream_target_init_ex(
+ zval *stream_arg,
+ bool is_input,
+ bool quiet,
+ php_io_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
+ 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;
+ }
+
+ if (Z_TYPE_P(stream_arg) == IS_RESOURCE) {
+ target->php_stream = (php_stream *) zend_fetch_resource2(
+ Z_RES_P(stream_arg),
+ quiet ? NULL : "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 = php_io_terminal_native_stream_from_php_stream(target->php_stream);
+ return true;
+ }
+
+ return false;
+}
+
+static zend_always_inline bool php_io_terminal_stream_target_init(
+ zval *stream_arg,
+ bool is_input,
+ php_io_terminal_stream_target *target
+)
+{
+ 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)) | ENABLE_WINDOW_INPUT;
+}
+
+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_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;
+ }
+
+ DWORD mode;
+ if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) {
+ return NULL;
+ }
+
+ 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;
+ }
+
+ DWORD raw_mode = php_io_terminal_make_raw_mode(mode);
+ if (!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) {
+ php_io_terminal_close_native_stream(restore_stream);
+ return NULL;
+ }
+
+ 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)
+{
+ CONSOLE_SCREEN_BUFFER_INFO info;
+
+ if (!php_io_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 *php_io_terminal_key_from_virtual_key(WORD vk)
+{
+ switch (vk) {
+ case VK_UP:
+ return ZSTR_INIT_LITERAL("up", false);
+ case VK_DOWN:
+ return ZSTR_INIT_LITERAL("down", false);
+ case VK_RIGHT:
+ return ZSTR_INIT_LITERAL("right", false);
+ case VK_LEFT:
+ return ZSTR_INIT_LITERAL("left", false);
+ case VK_RETURN:
+ return ZSTR_INIT_LITERAL("enter", false);
+ case VK_BACK:
+ return ZSTR_INIT_LITERAL("backspace", false);
+ case VK_ESCAPE:
+ return ZSTR_INIT_LITERAL("escape", false);
+ case VK_TAB:
+ return ZSTR_INIT_LITERAL("tab", false);
+ case VK_HOME:
+ return ZSTR_INIT_LITERAL("home", false);
+ case VK_END:
+ return ZSTR_INIT_LITERAL("end", false);
+ case VK_DELETE:
+ return ZSTR_INIT_LITERAL("delete", false);
+ case VK_PRIOR:
+ return ZSTR_INIT_LITERAL("pageup", false);
+ case VK_NEXT:
+ return ZSTR_INIT_LITERAL("pagedown", false);
+ case VK_F1:
+ return ZSTR_INIT_LITERAL("f1", false);
+ case VK_F2:
+ return ZSTR_INIT_LITERAL("f2", false);
+ case VK_F3:
+ return ZSTR_INIT_LITERAL("f3", false);
+ case VK_F4:
+ return ZSTR_INIT_LITERAL("f4", false);
+ case VK_F5:
+ return ZSTR_INIT_LITERAL("f5", false);
+ case VK_F6:
+ return ZSTR_INIT_LITERAL("f6", false);
+ case VK_F7:
+ return ZSTR_INIT_LITERAL("f7", false);
+ case VK_F8:
+ return ZSTR_INIT_LITERAL("f8", false);
+ case VK_F9:
+ return ZSTR_INIT_LITERAL("f9", false);
+ case VK_F10:
+ return ZSTR_INIT_LITERAL("f10", false);
+ case VK_F11:
+ return ZSTR_INIT_LITERAL("f11", false);
+ case VK_F12:
+ return ZSTR_INIT_LITERAL("f12", false);
+ default:
+ return NULL;
+ }
+}
+
+static zend_string *php_io_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 *php_io_terminal_key_from_input_record(const KEY_EVENT_RECORD *key, WCHAR *high_surrogate)
+{
+ zend_string *named_key = php_io_terminal_key_from_virtual_key(key->wVirtualKeyCode);
+ if (named_key != NULL) {
+ *high_surrogate = 0;
+ return named_key;
+ }
+ 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,
+ INPUT_RECORD *pending_key,
+ WCHAR *pending_key_high_surrogate
+)
+{
+ if (pending_key->Event.KeyEvent.wRepeatCount > 0) {
+ *record = *pending_key;
+ *high_surrogate = *pending_key_high_surrogate;
+ pending_key->Event.KeyEvent.wRepeatCount = 0;
+ return true;
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+
+ return true;
+}
+
+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;
+ WCHAR high_surrogate = *pending_high_surrogate;
+ 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)) {
+ 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;
+ }
+
+ 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;
+
+ while (true) {
+ INPUT_RECORD record;
+ DWORD remaining_ms = INFINITE;
+
+ if (wait_ms != INFINITE) {
+ ULONGLONG now = GetTickCount64();
+ remaining_ms = now >= deadline_ms ? 0 : (DWORD) (deadline_ms - now);
+ }
+
+ if (!php_io_terminal_read_console_record(
+ handle,
+ remaining_ms,
+ &record,
+ &high_surrogate,
+ pending_key,
+ pending_key_high_surrogate)) {
+ break;
+ }
+
+ if (record.EventType == WINDOW_BUFFER_SIZE_EVENT) {
+ result = ZSTR_INIT_LITERAL("resize", false);
+ break;
+ }
+
+ if (record.EventType == KEY_EVENT) {
+ KEY_EVENT_RECORD *key = &record.Event.KeyEvent;
+ if (!key->bKeyDown) {
+ 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 = prev_high;
+ }
+ break;
+ }
+ }
+ }
+
+ *pending_high_surrogate = high_surrogate;
+
+ if (mode_changed && !SetConsoleMode(handle, mode)) {
+ 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;
+ }
+
+ return result;
+}
+
+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,
+ DWORD wait_ms,
+ bool has_timeout,
+ bool *timed_out
+)
+{
+ *timed_out = false;
+ HANDLE handle = input;
+ DWORD mode;
+
+ if (handle == INVALID_HANDLE_VALUE || handle == NULL
+ || (stream != NULL && stream->writepos > stream->readpos)
+ || !GetConsoleMode(handle, &mode)) {
+ return NULL;
+ }
+
+ DWORD raw_mode = php_io_terminal_make_raw_mode(mode);
+ if (raw_mode != mode && !SetConsoleMode(handle, raw_mode)) {
+ return NULL;
+ }
+
+ bool mode_changed = raw_mode != mode;
+ WCHAR high_surrogate = 0;
+ smart_str secret = {0};
+ bool success = false;
+ bool failed = false;
+
+ zend_hrtime_t overall_deadline_ns = 0;
+ if (has_timeout) {
+ overall_deadline_ns = php_io_terminal_saturating_add_deadline(
+ zend_hrtime(),
+ ((zend_hrtime_t) wait_ms * 1000000ULL)
+ );
+ }
+
+ while (true) {
+ INPUT_RECORD record;
+ DWORD current_wait = INFINITE;
+
+ if (has_timeout) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= overall_deadline_ns) {
+ 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;
+ }
+ }
+
+ if (!php_io_terminal_read_console_record(
+ handle,
+ current_wait,
+ &record,
+ &high_surrogate,
+ pending_key,
+ pending_key_high_surrogate)) {
+ if (has_timeout && (current_wait == 0 || zend_hrtime() >= overall_deadline_ns)) {
+ *timed_out = true;
+ }
+ failed = true;
+ break;
+ }
+
+ if (record.EventType != KEY_EVENT) {
+ continue;
+ }
+
+ KEY_EVENT_RECORD *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->wVirtualKeyCode == 'D')
+ && (key->dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0)) {
+ failed = true;
+ break;
+ }
+
+ 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) {
+ 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 = php_io_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)) {
+ failed = true;
+ *timed_out = false;
+ }
+
+ if (success && !failed) {
+ return smart_str_extract(&secret);
+ }
+
+ smart_str_free(&secret);
+ return NULL;
+}
+#else
+/* POSIX implementation */
+
+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;
+ 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 php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode(
+ php_io_terminal_native_stream fd,
+ const php_io_terminal_identity *identity
+)
+{
+ 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;
+ }
+
+ struct termios mode;
+ if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) {
+ return NULL;
+ }
+
+ 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;
+ }
+
+ struct termios raw_mode = mode;
+ php_io_terminal_make_raw_mode(&raw_mode);
+
+ if (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) {
+ php_io_terminal_close_native_stream(restore_stream);
+ return NULL;
+ }
+
+ 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)
+{
+ struct winsize ws;
+
+ if (!php_io_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 php_io_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 php_io_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 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_nanoseconds = elapsed_ns % ZEND_NANO_IN_SEC;
+
+ if (elapsed_seconds > (uint64_t) remaining->tv_sec
+ || (elapsed_seconds == (uint64_t) remaining->tv_sec
+ && elapsed_nanoseconds >= (uint64_t) remaining->tv_nsec)) {
+ remaining->tv_sec = 0;
+ remaining->tv_nsec = 0;
+ return false;
+ }
+
+ remaining->tv_sec -= (time_t) elapsed_seconds;
+ if ((uint64_t) remaining->tv_nsec < elapsed_nanoseconds) {
+ remaining->tv_sec--;
+ remaining->tv_nsec += ZEND_NANO_IN_SEC;
+ }
+ 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 timespec *timeout,
+ bool allow_resize,
+ const sig_atomic_t *generation
+)
+{
+ if (stream != NULL && stream->writepos > stream->readpos) {
+ 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 PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ struct timespec remaining;
+ if (timeout != NULL) {
+ remaining = *timeout;
+ }
+
+ while (true) {
+ php_poll_event event;
+ const struct timespec *wait_timeout = 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;
+ }
+#endif
+
+ if (timeout != NULL) {
+ wait_timeout = &remaining;
+ start_ns = zend_hrtime();
+ }
+
+ int poll_result = php_poll_wait(poll_ctx, &event, 1, wait_timeout);
+ if (poll_result > 0) {
+ break;
+ }
+ if (poll_result == 0) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+
+ 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;
+ }
+#endif
+ if (timeout != NULL) {
+ zend_hrtime_t elapsed_ns = zend_hrtime() - start_ns;
+ if (UNEXPECTED(elapsed_ns == 0)
+ || !php_io_terminal_timespec_subtract_elapsed(&remaining, elapsed_ns)) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ }
+ continue;
+ }
+
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ ssize_t bytes_read = read(fd, byte, 1);
+ if (bytes_read == 0) {
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+ if (bytes_read < 0) {
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+}
+
+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,
+ bool has_overall_deadline,
+ zend_hrtime_t overall_deadline_ns
+)
+{
+ while (pending->length < pending->expected) {
+ unsigned char key;
+ struct timespec current_timeout;
+ const struct timespec *timeout = NULL;
+
+ 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 {
+ php_io_terminal_ns_to_timespec(overall_deadline_ns - now, ¤t_timeout);
+ }
+ timeout = ¤t_timeout;
+ }
+
+ int result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, false, NULL);
+ if (result == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ return NULL;
+ }
+ if (result == PHP_IO_TERMINAL_READ_EOF) {
+ zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0);
+ return NULL;
+ }
+ if (result != PHP_IO_TERMINAL_READ_SUCCESS) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ return NULL;
+ }
+
+ pending->bytes[pending->length++] = key;
+ if ((key & 0xc0) != 0x80) {
+ break;
+ }
+ }
+
+ zend_string *result = zend_string_init((const char *) pending->bytes, pending->length, false);
+ memset(pending, 0, sizeof(*pending));
+ return result;
+}
+
+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,
+ 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 = php_io_terminal_saturating_add_deadline(now, seq_timeout_ns);
+ if (has_overall_deadline && deadline_ns > overall_deadline_ns) {
+ deadline_ns = overall_deadline_ns;
+ }
+
+ 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);
+ 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] == '[') {
+ while (seq_len < sizeof(seq)) {
+ now = zend_hrtime();
+ if (now >= deadline_ns) {
+ 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);
+ 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;
+ }
+
+ seq[seq_len++] = next;
+ if (next >= 0x40 && next <= 0x7e) {
+ break;
+ }
+ }
+
+#define PHP_IO_TERMINAL_CSI_IS(literal) \
+ (memcmp(seq, literal, sizeof(literal) - 1) == 0)
+
+ if (seq_len == 3) {
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return ZSTR_INIT_LITERAL("up", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[B")) return ZSTR_INIT_LITERAL("down", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[C")) return ZSTR_INIT_LITERAL("right", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[D")) return ZSTR_INIT_LITERAL("left", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[H")) return ZSTR_INIT_LITERAL("home", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[F")) return ZSTR_INIT_LITERAL("end", false);
+ } 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)) {
+ 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;
+ }
+ }
+ }
+
+ 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,
+ 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,
+ 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,
+ bool has_overall_deadline,
+ zend_hrtime_t overall_deadline_ns
+)
+{
+ switch (key) {
+ case '\r':
+ case '\n':
+ return ZSTR_INIT_LITERAL("enter", false);
+ case '\t':
+ return ZSTR_INIT_LITERAL("tab", false);
+ case 0x7f:
+ case '\b':
+ return ZSTR_INIT_LITERAL("backspace", false);
+ case 0x1b:
+ 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, pending, has_overall_deadline, overall_deadline_ns
+ );
+ }
+}
+
+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
+)
+{
+ 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;
+ }
+
+ 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;
+ }
+
+ 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)
+ 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;
+ }
+
+ bool has_overall_deadline = (timeout != 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(timeout)
+ );
+ }
+
+ zend_string *result = NULL;
+
+ if (pending->length > 0) {
+ result = php_io_terminal_finish_utf8_sequence(
+ fd, stream, poll_ctx, pending,
+ 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);
+ }
+ }
+
+ if (mode_changed && tcsetattr(fd, TCSANOW, &mode) != 0) {
+#if defined(SIGWINCH)
+ if (resize_handler_installed) {
+ php_io_terminal_restore_resize_handler();
+ }
+#endif
+ if (result != NULL) {
+ 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;
+ }
+
+#if defined(SIGWINCH)
+ if (resize_handler_installed) {
+ php_io_terminal_restore_resize_handler();
+ }
+#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 timespec *timeout,
+ bool *timed_out
+)
+{
+ *timed_out = false;
+ int fd = input;
+ if (fd < 0 || isatty(fd) != 1) {
+ return NULL;
+ }
+
+ struct termios mode;
+ if (tcgetattr(fd, &mode) != 0) {
+ return NULL;
+ }
+
+ php_poll_ctx *poll_ctx = php_io_terminal_create_poll_context(fd);
+ if (poll_ctx == NULL) {
+ return NULL;
+ }
+
+ 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;
+ }
+
+ bool has_overall_deadline = (timeout != 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(timeout)
+ );
+ }
+
+ smart_str secret = {0};
+ bool success = false;
+
+ 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) {
+ current_timeout.tv_sec = 0;
+ current_timeout.tv_nsec = 0;
+ } else {
+ 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:
+ switch (key) {
+ case '\r':
+ case '\n':
+ success = true;
+ goto restore;
+ case 0x7f:
+ 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) {
+ 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);
+ }
+ 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, step_timeout_ptr, false, NULL);
+ if (read_res == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ /* 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 == 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) {
+ 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);
+ }
+ 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, 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: 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;
+ }
+ }
+ break;
+ }
+
+ if (next == 'O') {
+ if (has_overall_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= overall_deadline_ns) {
+ 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);
+ }
+ 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;
+ 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:
+ {
+ size_t seq_len = php_io_terminal_utf8_sequence_len(key);
+ if (seq_len == 1) {
+ smart_str_appendc(&secret, (char) key);
+ } 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++) {
+ 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) {
+ cont_timeout.tv_sec = 0;
+ cont_timeout.tv_nsec = 0;
+ } else {
+ 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;
+ invalid_byte = seq[i];
+ break;
+ }
+ }
+ if (invalid_continuation) {
+ 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 (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;
+}
+
+/* Io\Terminal\TerminalSize methods */
+PHP_METHOD(Io_Terminal_TerminalSize, __construct)
+{
+ 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);
+ 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\SystemModeToken methods */
+PHP_METHOD(Io_Terminal_SystemModeToken, __construct)
+{
+ zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemModeToken");
+}
+
+/* Io\Terminal\SystemTerminal methods */
+PHP_METHOD(Io_Terminal_SystemTerminal, __construct)
+{
+ zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\SystemTerminal");
+}
+
+PHP_METHOD(Io_Terminal_SystemTerminal, fromStdio)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ 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;
+ 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_SystemTerminal, fromStreams)
+{
+ zval *input_arg;
+ zval *output_arg = NULL;
+
+ 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();
+
+ 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");
+ }
+ 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) {
+ if (!EG(exception)) {
+ zend_argument_value_error(2, "must be a valid stream resource or null");
+ }
+ RETURN_THROWS();
+ }
+ }
+
+ 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);
+
+ 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);
+ }
+
+ 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_SystemTerminal, getSize)
+{
+ 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->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_NULL();
+ }
+
+ intern->last_cols = columns;
+ intern->last_rows = rows;
+ intern->has_last_size = true;
+
+ 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_SystemTerminal, enableRawMode)
+{
+ 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 (!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();
+ }
+
+ 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();
+ }
+
+ intern->identity = identity;
+ intern->has_identity = true;
+
+ 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();
+ }
+
+ 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;
+
+ 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();
+
+ 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_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();
+ }
+
+ 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 (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 (!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;
+ }
+
+ 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;
+ }
+
+ RETURN_FALSE;
+}
+
+PHP_METHOD(Io_Terminal_SystemTerminal, readKey)
+{
+ php_date_time_duration *timeout_duration = NULL;
+ php_date_time_duration *seq_timeout_duration = NULL;
+
+ 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();
+ }
+
+ 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;
+ 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;
+ }
+ }
+
+ php_io_terminal_stream_target stream;
+ if (!php_io_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) 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 *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 timespec timeout;
+ struct timespec sequence_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L};
+ const struct timespec *timeout_ptr = NULL;
+ bool is_non_blocking = false;
+
+ if (timeout_duration != NULL) {
+ is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0);
+ timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds);
+ timeout.tv_nsec = timeout_duration->duration.nanoseconds;
+ timeout_ptr = &timeout;
+ }
+
+ if (is_non_blocking) {
+ sequence_timeout.tv_sec = 0;
+ sequence_timeout.tv_nsec = 0;
+ } else if (seq_timeout_duration != NULL) {
+ sequence_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds);
+ sequence_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds;
+ }
+
+ zend_string *key = php_io_terminal_read_stream_key(
+ stream.native_stream,
+ stream.php_stream,
+ timeout_ptr,
+ &sequence_timeout,
+ &intern->pending_utf8
+ );
+#endif
+
+ if (EG(exception)) {
+ if (key != NULL) {
+ zend_string_release(key);
+ }
+ RETURN_THROWS();
+ }
+
+ if (key == NULL) {
+ RETURN_NULL();
+ }
+
+ 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;
+ 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);
+ }
+
+ RETURN_STR(key);
+}
+
+#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));
+ }
+ }
+
+ bool restore_failed = false;
+ if (mode_changed && !SetConsoleMode(handle, original_mode)) {
+ restore_failed = true;
+ }
+
+ if (read_failed) {
+ efree(wline);
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read line from console", 0);
+ 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 */
+ }
+
+ /* 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);
+ 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
+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 = '\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);
+ if (ch == '\n') {
+ break;
+ }
+ } else if (n == 0) {
+ eof_reached = true;
+ break; /* EOF */
+ } else {
+ if (errno == EINTR) {
+ continue;
+ }
+#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;
+ }
+
+ *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;
+
+ 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;
+ }
+ return buf;
+ }
+
+ if (fd >= 0) {
+ return php_io_terminal_read_line_from_fd(fd, out_len, is_eof);
+ }
+
+ return NULL;
+ }
+
+ smart_str line = {0};
+ 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) {
+ 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;
+ }
+ }
+ }
+
+ 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(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;
+ }
+#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 (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) {
+ if (stream != NULL) {
+ 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
+
+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 (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();
+ }
+
+#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;
+
+ 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);
+
+#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();
+ }
+
+ 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,
+ wait_ms,
+ has_timeout,
+ &timed_out
+ );
+#else
+ 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);
+ }
+ RETURN_THROWS();
+}
+
+PHP_MINIT_FUNCTION(terminal)
+{
+ 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(io_exception_ce);
+
+ php_io_terminal_key_ce = register_class_Io_Terminal_Key();
+
+ 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;
+ 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;
+ 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;
+
+#ifdef ZTS
+# ifndef PHP_WIN32
+# ifdef SIGWINCH
+ php_io_terminal_resize_mutex = tsrm_mutex_alloc();
+# endif
+# ifndef PHP_IO_TERMINAL_HAVE_PTSNAME_R
+ php_io_terminal_ptsname_mutex = tsrm_mutex_alloc();
+# endif
+# endif
+#endif
+
+ return SUCCESS;
+}
+
+PHP_MSHUTDOWN_FUNCTION(terminal)
+{
+#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
+# 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;
+}
+
+PHP_RINIT_FUNCTION(terminal)
+{
+ php_io_terminal_active_shared_modes = NULL;
+ return SUCCESS;
+}
+
+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);
+ }
+ 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..1e56d7eef25d
--- /dev/null
+++ b/ext/standard/io_terminal.stub.php
@@ -0,0 +1,121 @@
+
+--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
new file mode 100644
index 000000000000..c211fc9ea803
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_create.phpt
@@ -0,0 +1,113 @@
+--TEST--
+Io\Terminal\SystemTerminal: fromStdio, fromStreams, construction and serialization restrictions
+--FILE--
+getMessage(), PHP_EOL;
+}
+
+try {
+ SystemTerminal::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) {
+ try {
+ new $class();
+ } catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+ }
+}
+
+// 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;
+ }
+}
+
+// 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);
+var_dump($unserialized instanceof TerminalSize, $unserialized->cols, $unserialized->rows);
+
+// Clone rejection
+foreach ([$t1, $t2] as $obj) {
+ try {
+ clone $obj;
+ } catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+ }
+}
+
+// Serialization rejection for SystemTerminal
+try {
+ serialize($t1);
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+object(Io\Terminal\SystemTerminal)#1 (0) {
+}
+object(Io\Terminal\SystemTerminal)#2 (0) {
+}
+object(Io\Terminal\SystemTerminal)#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
+int(80)
+int(24)
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0
+Error: Cannot modify readonly property Io\Terminal\TerminalSize::$cols
+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
diff --git a/ext/standard/tests/terminal/terminal_identity.phpt b/ext/standard/tests/terminal/terminal_identity.phpt
new file mode 100644
index 000000000000..ce8c8f75c681
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_identity.phpt
@@ -0,0 +1,121 @@
+--TEST--
+Io\Terminal\SystemTerminal: 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 = SystemTerminal::fromStreams($pipes1[0]);
+$t1_dup = SystemTerminal::fromStreams($pipes1[1]);
+$t2 = SystemTerminal::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());
+
+// 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");
+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 = SystemTerminal::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);
+}
+foreach ($pipes3 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc2);
+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
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..b3b4521c58ac
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt
@@ -0,0 +1,97 @@
+--TEST--
+Io\Terminal\Terminal: mockable terminal boundary and foreign token rejection
+--FILE--
+implementsInterface(Terminal::class));
+
+$rcToken = new ReflectionClass(SystemModeToken::class);
+var_dump($rcToken->implementsInterface(ModeToken::class));
+
+// Application-level fake
+class FakeModeToken implements ModeToken {}
+
+class FakeTerminal implements Terminal
+{
+ 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');
+ } finally {
+ $term->restoreMode($token);
+ }
+}
+
+$fake = new FakeTerminal();
+echo promptPassword($fake), PHP_EOL;
+var_dump($fake->isRaw);
+
+// Native restoreMode rejects foreign ModeToken implementations
+$fp = fopen('php://temp', 'r+');
+$native = SystemTerminal::fromStreams($fp);
+
+try {
+ $native->restoreMode(new FakeModeToken());
+ echo "FAIL: native accepted foreign ModeToken\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\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
new file mode 100644
index 000000000000..3a2cd94cd5d6
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt
@@ -0,0 +1,154 @@
+--TEST--
+Io\Terminal\SystemTerminal: overlapping raw-mode sessions, ownership validation, 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,
+);
+
+$proc2 = proc_open(
+ [PHP_BINARY, '-r', 'fgets(STDIN);'],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+
+// 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]);
+
+// 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 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: Token survives Terminal destruction when held externally
+$tTemp = SystemTerminal::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));
+
+// After restoration, the token is consumed; reusing it throws ValueError
+try {
+ $t1->restoreMode($mTemp);
+ echo "FAIL: stale token after restoration 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();
+
+var_dump($m1 instanceof ModeToken);
+var_dump($m2 instanceof ModeToken);
+
+// Out-of-order: restore older token m1 first
+var_dump($t1->restoreMode($m1));
+
+// Now restore m2
+var_dump($t2->restoreMode($m2));
+
+// Test 6: In-order restore (m2 then m1)
+$m1 = $t1->enableRawMode();
+$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 = 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 = SystemTerminal::fromStreams($pipes[0]);
+var_dump($tVerify->restoreMode());
+
+fwrite($pipes[0], "exit\n");
+fwrite($pipes2[0], "exit\n");
+unset($t1, $t2, $tUnrelated, $tVerify);
+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\SystemTerminal::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)
+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
new file mode 100644
index 000000000000..ed51fb1f4fff
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt
@@ -0,0 +1,151 @@
+--TEST--
+Io\Terminal\SystemTerminal: 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,
+);
+
+$terminal = SystemTerminal::fromStreams($pipes[0]);
+
+// Explicit token restore on valid terminal
+$token = $terminal->enableRawMode();
+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 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]);
+$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));
+
+// Once restored, reusing the token throws ValueError
+try {
+ $terminal->restoreMode($token3);
+ echo "FAIL: consumed token was accepted\n";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// 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);'],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+$terminalClosed = SystemTerminal::fromStreams($pipes2[0]);
+$tokenClosed = $terminalClosed->enableRawMode();
+var_dump($tokenClosed instanceof ModeToken);
+
+// Close the underlying stream
+fclose($pipes2[0]);
+
+// Restoration succeeds via the record's owned descriptor
+var_dump($terminalClosed->restoreMode($tokenClosed));
+
+// Token is now consumed; reusing it throws ValueError
+try {
+ $terminalClosed->restoreMode($tokenClosed);
+ echo "FAIL: consumed token was accepted\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) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc2);
+
+fwrite($pipes[0], "exit\n");
+unset($terminal, $token3, $terminalClosed, $tokenClosed);
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--EXPECT--
+enableRawMode: ModeToken
+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)
+bool(true)
+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
new file mode 100644
index 000000000000..b73108a0a465
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt
@@ -0,0 +1,23 @@
+--TEST--
+Io\Terminal\SystemTerminal: enableRawMode throws TerminalException for non-terminal streams
+--FILE--
+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--
+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
new file mode 100644
index 000000000000..84f5933e45e9
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Io\Terminal\SystemTerminal: readKey with Time\Duration timeouts and parameter validation
+--FILE--
+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), Duration::fromSeconds(1)->negate());
+ echo "FAIL: accepted negative sequence timeout\n";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// Non-terminal stream throws TerminalException
+$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;
+}
+
+?>
+--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
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..ddea9474ae05
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt
@@ -0,0 +1,118 @@
+--TEST--
+Io\Terminal\SystemTerminal: readKey null 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 = SystemTerminal::fromStreams($pipes[0]);
+$terminal->enableRawMode();
+
+// 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 === null);
+var_dump($elapsed_ms < 20.0);
+
+// 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 === null);
+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 = 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;
+}
+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\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
new file mode 100644
index 000000000000..5bb89bed0cf2
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_partial_escape.phpt
@@ -0,0 +1,154 @@
+--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,
+);
+
+$r0 = fgets($pipes1[2]);
+$terminal1 = SystemTerminal::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;
+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 after release.
+// Because [ does not arrive before the 30ms deadline, the first call returns Key::Escape.
+// The subsequent call reads [ independently.
+$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],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+
+$r0 = fgets($pipes2[2]);
+$terminal2 = SystemTerminal::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;
+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_sequences.phpt b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt
new file mode 100644
index 000000000000..31c8ecd783a9
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt
@@ -0,0 +1,86 @@
+--TEST--
+Io\Terminal\SystemTerminal: 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 = SystemTerminal::fromStreams($pipes[0]);
+$terminal->enableRawMode();
+$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
+Up
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..9ad908655854
--- /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 = SystemTerminal::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_key_utf8_retain.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt
new file mode 100644
index 000000000000..3382f116ab87
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt
@@ -0,0 +1,214 @@
+--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,
+);
+
+$r0 = fgets($pipes1[2]);
+$terminal1 = SystemTerminal::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;
+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(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],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+
+$r0 = fgets($pipes2[2]);
+$terminal2 = SystemTerminal::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;
+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);
+
+// 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 (1000ms).
+$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 = SystemTerminal::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 (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;
+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
+k2 is euro: true
+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_key_utf8_timing.phpt b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt
new file mode 100644
index 000000000000..489b43bd4cee
--- /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 = SystemTerminal::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
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..07ae421d0f32
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_line.phpt
@@ -0,0 +1,195 @@
+--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);
+
+// 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)
+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"
+string(0) ""
+string(0) ""
+string(14) "line with crlf"
+string(0) ""
+NULL
+string(12) "ππ¦π"
+string(38) "Hello π¨βπ©βπ§βπ¦ Family"
+string(7) "eΜ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
new file mode 100644
index 000000000000..4cceffa17481
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt
@@ -0,0 +1,157 @@
+--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. Cross-instance restoreMode($token) succeeds and restores mode across wrappers
+$restored = $t2->restoreMode($token);
+var_dump($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());
+
+// 5. Subsequent readLine calls continue to work normally
+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
+$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
+$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);
+
+// 8. 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)
+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
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
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"
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..0cf9efbbf9e6
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_secret.phpt
@@ -0,0 +1,53 @@
+--TEST--
+Io\Terminal\SystemTerminal: readSecret parameter validation and non-tty failure handling
+--FILE--
+readSecret();
+ echo "FAIL: readSecret succeeded on non-tty\n";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// readSecret rejects invalid argument type
+try {
+ $nonTty->readSecret('prompt');
+ 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;
+}
+
+?>
+--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
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..f794cbdee1c3
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt
@@ -0,0 +1,319 @@
+--TEST--
+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,
+ );
+} 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 = SystemTerminal::fromStreams($pipes1[0]);
+
+try {
+ $secret = $terminal1->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes1[0], "done\n");
+unset($terminal1);
+foreach ($pipes1 as $pipe) {
+ if (is_resource($pipe)) fclose($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);';
+$proc2 = proc_open(
+ [PHP_BINARY, '-r', $code2],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+
+$terminal2 = SystemTerminal::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 = SystemTerminal::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 = SystemTerminal::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 = SystemTerminal::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);
+
+// 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 = SystemTerminal::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 = SystemTerminal::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 = SystemTerminal::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 = SystemTerminal::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 = SystemTerminal::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 = SystemTerminal::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
+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..700b2de4bdbc
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt
@@ -0,0 +1,298 @@
+--TEST--
+Io\Terminal\SystemTerminal: 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 = SystemTerminal::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 ""
+$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', $code2],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+$r0 = fgets($pipes2[2]);
+$terminal2 = SystemTerminal::fromStreams($pipes2[0]);
+$terminal2->enableRawMode();
+
+fwrite($pipes2[0], "START\n");
+$r1 = fgets($pipes2[2]);
+
+$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
+$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', $code3],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes3,
+);
+$r0 = fgets($pipes3[2]);
+$terminal3 = SystemTerminal::fromStreams($pipes3[0]);
+$terminal3->enableRawMode();
+
+fwrite($pipes3[0], "START\n");
+$r1 = fgets($pipes3[2]);
+
+$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
+$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', $code4],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes4,
+);
+$r0 = fgets($pipes4[2]);
+$terminal4 = SystemTerminal::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";
+} 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 = SystemTerminal::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);
+
+// 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 = SystemTerminal::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 = SystemTerminal::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)
+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
+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
new file mode 100644
index 000000000000..e53b22fc8192
--- /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 = SystemTerminal::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
diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt
new file mode 100644
index 000000000000..6b857c8a5d06
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_size.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Io\Terminal\SystemTerminal: getSize returns null for non-terminal streams without environment fallback
+--ENV--
+COLUMNS=100
+LINES=30
+--FILE--
+getSize());
+
+?>
+--EXPECT--
+NULL