diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
index c497855f7347..c0b95563215e 100644
--- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
+++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
@@ -30,6 +30,11 @@ Io\Poll\InactiveWatcherException
Io\Poll\InvalidHandleException
Io\Poll\PollException
Io\Poll\Watcher
+Io\Terminal\Key
+Io\Terminal\ModeToken
+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..072d5be06907
--- /dev/null
+++ b/ext/standard/io_terminal.c
@@ -0,0 +1,4167 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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)
+#endif
+
+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;
+
+#ifdef PHP_WIN32
+typedef struct php_io_terminal_identity {
+ php_io_terminal_native_stream stream;
+ bool is_console;
+ bool is_non_tty_stream;
+ zend_ulong stream_res_id;
+ php_stream *php_stream_ptr;
+} php_io_terminal_identity;
+#else
+typedef struct php_io_terminal_identity {
+ php_io_terminal_native_stream stream;
+ dev_t dev;
+ bool has_dev;
+ pid_t sid;
+ bool has_sid;
+ bool is_non_tty_stream;
+ zend_ulong stream_res_id;
+ php_stream *php_stream_ptr;
+} php_io_terminal_identity;
+#endif
+
+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;
+ uint32_t refcount;
+ pid_t owner_pid;
+ bool is_non_tty;
+ bool is_restored;
+ struct php_io_terminal_shared_mode *prev;
+ struct php_io_terminal_shared_mode *next;
+} php_io_terminal_shared_mode;
+
+typedef struct php_io_terminal_mode_token_object {
+ php_io_terminal_shared_mode *shared;
+ bool valid;
+ pid_t owner_pid;
+ zend_object std;
+} php_io_terminal_mode_token_object;
+
+typedef struct php_io_terminal_object {
+ zval input_stream_val;
+ zval output_stream_val;
+ php_io_terminal_identity identity;
+ bool has_identity;
+ 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;
+#endif
+ php_io_terminal_utf8_pending pending_utf8;
+ bool last_secret_ended_in_cr;
+ 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_object_handlers php_io_terminal_mode_token_handlers;
+static zend_object_handlers php_io_terminal_object_handlers;
+ZEND_TLS php_io_terminal_shared_mode *php_io_terminal_active_shared_modes;
+
+#define PHP_IO_TERMINAL_READ_EOF (-2)
+#define PHP_IO_TERMINAL_READ_ERROR (-1)
+#define PHP_IO_TERMINAL_READ_TIMEOUT 0
+#define PHP_IO_TERMINAL_READ_SUCCESS 1
+#define PHP_IO_TERMINAL_READ_RESIZE 2
+
+#if !defined(PHP_WIN32)
+# if defined(HAVE_PTSNAME_R) || defined(_GNU_SOURCE) || defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__)
+# define PHP_IO_TERMINAL_HAVE_PTSNAME_R 1
+# 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
+
+#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 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
+}
+
+static void php_io_terminal_shared_mode_addref(php_io_terminal_shared_mode *shared)
+{
+ shared->refcount++;
+}
+
+static void php_io_terminal_close_native_stream(php_io_terminal_native_stream stream);
+
+static void php_io_terminal_shared_mode_delref(php_io_terminal_shared_mode *shared)
+{
+ if (--shared->refcount == 0) {
+ if (php_io_terminal_native_stream_is_valid(shared->restore_stream)) {
+ php_io_terminal_close_native_stream(shared->restore_stream);
+ shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM;
+ }
+ efree(shared);
+ }
+}
+
+static void php_io_terminal_remove_active_shared_mode(php_io_terminal_shared_mode *shared)
+{
+ if (shared->prev != NULL) {
+ shared->prev->next = shared->next;
+ } else if (php_io_terminal_active_shared_modes == shared) {
+ php_io_terminal_active_shared_modes = shared->next;
+ }
+
+ if (shared->next != NULL) {
+ shared->next->prev = shared->prev;
+ }
+
+ shared->prev = NULL;
+ shared->next = NULL;
+}
+
+static php_io_terminal_native_stream php_io_terminal_native_stream_from_php_stream(php_stream *stream);
+
+#ifdef PHP_WIN32
+static bool php_io_terminal_get_identity(php_io_terminal_native_stream handle, php_io_terminal_identity *identity)
+{
+ memset(identity, 0, sizeof(*identity));
+ identity->stream = handle;
+ identity->is_console = false;
+ if (!php_io_terminal_native_stream_is_valid(handle)) {
+ 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)
+{
+ if (first->is_non_tty_stream && second->is_non_tty_stream) {
+ if (first->stream_res_id != 0 && second->stream_res_id != 0) {
+ return first->stream_res_id == second->stream_res_id;
+ }
+ return first->php_stream_ptr == second->php_stream_ptr;
+ }
+ return first->is_console && second->is_console && !first->is_non_tty_stream && !second->is_non_tty_stream;
+}
+#else
+#ifndef PATH_MAX
+# define PATH_MAX 1024
+#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->is_non_tty_stream && second->is_non_tty_stream) {
+ if (first->stream_res_id != 0 && second->stream_res_id != 0) {
+ return first->stream_res_id == second->stream_res_id;
+ }
+ return first->php_stream_ptr == second->php_stream_ptr;
+ }
+
+ if (first->is_non_tty_stream || second->is_non_tty_stream) {
+ return false;
+ }
+
+ if (first->has_dev && second->has_dev) {
+ return first->dev == second->dev;
+ }
+
+ 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;
+ pid_t current_pid = getpid();
+ while (curr != NULL) {
+ if (curr->owner_pid == current_pid && php_io_terminal_identities_match(&curr->identity, identity)) {
+ return curr;
+ }
+ curr = curr->next;
+ }
+ return NULL;
+}
+
+/* Fault-injection hooks are compiled only into debug builds (ZEND_DEBUG is
+ * always defined by php-src, as 0 or 1, so its value must be tested) or into
+ * private builds that explicitly define PHP_TERMINAL_TEST_BUILD. */
+#if ZEND_DEBUG || defined(PHP_TERMINAL_TEST_BUILD)
+# define PHP_IO_TERMINAL_TEST_SEAM_ENABLED 1
+#else
+# define PHP_IO_TERMINAL_TEST_SEAM_ENABLED 0
+#endif
+
+static inline const char *php_io_terminal_get_test_seam(void)
+{
+#if PHP_IO_TERMINAL_TEST_SEAM_ENABLED
+ return getenv("PHP_TERMINAL_TEST_SEAM");
+#else
+ return NULL;
+#endif
+}
+
+static inline void php_io_terminal_clear_test_seam(void)
+{
+#if PHP_IO_TERMINAL_TEST_SEAM_ENABLED
+#ifndef PHP_WIN32
+ unsetenv("PHP_TERMINAL_TEST_SEAM");
+#else
+ SetEnvironmentVariableA("PHP_TERMINAL_TEST_SEAM", NULL);
+#endif
+#endif
+}
+
+static bool php_io_terminal_restore_shared_mode(const php_io_terminal_shared_mode *shared)
+{
+ if (shared->is_non_tty) {
+ return true;
+ }
+ if (!php_io_terminal_native_stream_is_valid(shared->restore_stream)) {
+ return false;
+ }
+
+ const char *seam = php_io_terminal_get_test_seam();
+ if (seam != NULL && strcmp(seam, "fail_restore_once") == 0) {
+ php_io_terminal_clear_test_seam();
+ return false;
+ }
+
+#ifdef PHP_WIN32
+ if (!SetConsoleMode(shared->restore_stream, shared->saved_mode)) {
+ return false;
+ }
+ 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 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 (token_obj->owner_pid != getpid()) {
+ if (error_msg != NULL) {
+ *error_msg = "Cannot restore terminal mode from another process";
+ }
+ return false;
+ }
+
+ if (shared->is_non_tty) {
+ if (shared->lease_count > 1) {
+ shared->lease_count--;
+ } else {
+ shared->lease_count = 0;
+ shared->is_restored = true;
+ php_io_terminal_remove_active_shared_mode(shared);
+ php_io_terminal_shared_mode_delref(shared);
+ }
+ token_obj->valid = false;
+ token_obj->shared = NULL;
+ php_io_terminal_shared_mode_delref(shared);
+ return true;
+ }
+
+ if (shared->lease_count > 1) {
+ shared->lease_count--;
+ token_obj->valid = false;
+ token_obj->shared = NULL;
+ php_io_terminal_shared_mode_delref(shared);
+ return true;
+ }
+
+ /* lease_count == 1: attempt hardware/OS restore */
+ if (!php_io_terminal_restore_shared_mode(shared)) {
+ if (error_msg != NULL) {
+ *error_msg = "Failed to restore terminal mode";
+ }
+ return false;
+ }
+
+ shared->lease_count = 0;
+ shared->is_restored = true;
+ php_io_terminal_remove_active_shared_mode(shared);
+ php_io_terminal_shared_mode_delref(shared);
+
+ token_obj->valid = false;
+ token_obj->shared = NULL;
+ php_io_terminal_shared_mode_delref(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 (intern->owner_pid == getpid()) {
+ if (!php_io_terminal_release_token_lease(intern, NULL)) {
+ intern->valid = false;
+ php_io_terminal_shared_mode_delref(intern->shared);
+ intern->shared = NULL;
+ }
+ } else {
+ intern->valid = false;
+ php_io_terminal_shared_mode_delref(intern->shared);
+ intern->shared = NULL;
+ }
+ }
+
+ 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;
+ intern->owner_pid = getpid();
+
+ 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_mode_token_ce);
+ intern = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(return_value);
+ php_io_terminal_shared_mode_addref(shared);
+ intern->shared = shared;
+ intern->valid = true;
+ intern->owner_pid = getpid();
+}
+
+static void php_io_terminal_free_obj(zend_object *object)
+{
+ php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZOBJ(object);
+
+ 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->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;
+#endif
+ memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8));
+ intern->last_secret_ended_in_cr = false;
+
+ zend_object_std_init(&intern->std, ce);
+ object_properties_init(&intern->std, ce);
+ 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;
+
+ 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) {
+ return INVALID_HANDLE_VALUE;
+ }
+
+ int opt = 0;
+ int optlen = sizeof(opt);
+ if (getsockopt((SOCKET) descriptor, SOL_SOCKET, SO_TYPE, (char *) &opt, &optlen) == 0) {
+ return (HANDLE)(intptr_t) descriptor;
+ }
+
+ if ((uintptr_t) descriptor > INT_MAX) {
+ return INVALID_HANDLE_VALUE;
+ }
+
+ intptr_t os_handle = _get_osfhandle((int) descriptor);
+ return os_handle == -1 ? INVALID_HANDLE_VALUE : (HANDLE) os_handle;
+#else
+ php_socket_t descriptor = (php_socket_t) -1;
+
+ if (php_stream_cast(stream, PHP_STREAM_AS_FD | PHP_STREAM_CAST_INTERNAL, (void **) &descriptor, 0) != SUCCESS) {
+ return -1;
+ }
+
+ if (descriptor < 0 || descriptor > INT_MAX) {
+ return -1;
+ }
+
+ 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,
+ php_stream *stream,
+ const php_io_terminal_identity *identity,
+ bool is_console
+)
+{
+ php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity);
+ if (shared != NULL) {
+ const char *seam = php_io_terminal_get_test_seam();
+ if (seam != NULL && strcmp(seam, "fail_reapply") == 0) {
+ return NULL;
+ }
+ if (is_console) {
+ DWORD current_mode;
+ if (!GetConsoleMode(handle, ¤t_mode)) {
+ return NULL;
+ }
+ DWORD raw = php_io_terminal_make_raw_mode(current_mode);
+ if (current_mode != raw) {
+ if (!SetConsoleMode(handle, raw)) {
+ return NULL;
+ }
+ }
+ }
+ shared->lease_count++;
+ return shared;
+ }
+
+ if (!is_console) {
+ shared = emalloc(sizeof(*shared));
+ shared->identity = *identity;
+ shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM;
+ shared->saved_mode = 0;
+ shared->lease_count = 1;
+ shared->refcount = 1;
+ shared->owner_pid = getpid();
+ shared->is_non_tty = true;
+ shared->is_restored = false;
+ shared->prev = NULL;
+ shared->next = php_io_terminal_active_shared_modes;
+ if (php_io_terminal_active_shared_modes != NULL) {
+ php_io_terminal_active_shared_modes->prev = shared;
+ }
+ php_io_terminal_active_shared_modes = shared;
+ return shared;
+ }
+
+ DWORD mode;
+ if (!php_io_terminal_native_stream_is_valid(handle) || !GetConsoleMode(handle, &mode)) {
+ return NULL;
+ }
+
+ 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);
+ const char *seam = php_io_terminal_get_test_seam();
+ if ((seam != NULL && strcmp(seam, "fail_first_acquire") == 0) || !SetConsoleMode(handle, raw_mode)) {
+ php_io_terminal_close_native_stream(restore_stream);
+ return NULL;
+ }
+
+ DWORD actual_mode;
+ if ((seam != NULL && strcmp(seam, "fail_postcondition") == 0) ||
+ (GetConsoleMode(handle, &actual_mode) && (actual_mode & (ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT)) != 0)) {
+ /* Roll back to original saved mode before closing restore handle */
+ if (!SetConsoleMode(handle, mode)) {
+ /* Rollback failed: preserve restoration state so cleanup or retry can restore it */
+ shared = emalloc(sizeof(*shared));
+ shared->identity = *identity;
+ shared->restore_stream = restore_stream;
+ shared->saved_mode = mode;
+ shared->lease_count = 0;
+ shared->refcount = 1;
+ shared->owner_pid = getpid();
+ shared->is_non_tty = false;
+ shared->is_restored = false;
+ shared->prev = NULL;
+ shared->next = php_io_terminal_active_shared_modes;
+ if (php_io_terminal_active_shared_modes != NULL) {
+ php_io_terminal_active_shared_modes->prev = shared;
+ }
+ php_io_terminal_active_shared_modes = shared;
+ return NULL;
+ }
+ php_io_terminal_close_native_stream(restore_stream);
+ return NULL;
+ }
+
+ shared = emalloc(sizeof(*shared));
+ shared->identity = *identity;
+ shared->restore_stream = restore_stream;
+ shared->saved_mode = mode;
+ shared->lease_count = 1;
+ shared->refcount = 1;
+ shared->owner_pid = getpid();
+ shared->is_non_tty = false;
+ shared->is_restored = false;
+ shared->prev = NULL;
+ shared->next = php_io_terminal_active_shared_modes;
+ if (php_io_terminal_active_shared_modes != NULL) {
+ 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_INSERT:
+ return ZSTR_INIT_LITERAL("insert", 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)
+{
+ if (key->wVirtualKeyCode == VK_TAB && (key->dwControlKeyState & SHIFT_PRESSED) != 0) {
+ *high_surrogate = 0;
+ return ZSTR_INIT_LITERAL("shifttab", false);
+ }
+
+ zend_string *named_key = php_io_terminal_key_from_virtual_key(key->wVirtualKeyCode);
+ if (named_key != NULL) {
+ *high_surrogate = 0;
+ 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_console_key(
+ php_io_terminal_native_stream input,
+ 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 (!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_console_secret(
+ php_io_terminal_native_stream input,
+ INPUT_RECORD *pending_key,
+ WCHAR *pending_key_high_surrogate,
+ WCHAR *pending_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 || !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 = *pending_high_surrogate;
+ 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);
+ }
+ }
+ }
+ }
+
+ *pending_high_surrogate = high_surrogate;
+
+ 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 raw mode & size */
+
+static void php_io_terminal_make_raw_mode(struct termios *mode)
+{
+ mode->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON);
+ /* Do NOT disable OPOST; preserve output processing to avoid staircase newlines */
+ mode->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
+ mode->c_cflag &= ~(CSIZE | PARENB);
+ mode->c_cflag |= CS8;
+ mode->c_cc[VMIN] = 1;
+ mode->c_cc[VTIME] = 0;
+}
+
+static bool php_io_terminal_termios_needs_raw_mode(const struct termios *current, const struct termios *desired)
+{
+ tcflag_t iflag_mask = IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON;
+ tcflag_t lflag_mask = ECHO | ECHONL | ICANON | ISIG | IEXTEN;
+ tcflag_t cflag_mask = CSIZE | PARENB;
+
+ if ((current->c_iflag & iflag_mask) != (desired->c_iflag & iflag_mask)) {
+ return true;
+ }
+ if ((current->c_lflag & lflag_mask) != (desired->c_lflag & lflag_mask)) {
+ return true;
+ }
+ if ((current->c_cflag & cflag_mask) != (desired->c_cflag & cflag_mask)) {
+ return true;
+ }
+ if (current->c_cc[VMIN] != desired->c_cc[VMIN]) {
+ return true;
+ }
+ if (current->c_cc[VTIME] != desired->c_cc[VTIME]) {
+ return true;
+ }
+ return false;
+}
+
+static php_io_terminal_shared_mode *php_io_terminal_acquire_raw_mode(
+ php_io_terminal_native_stream fd,
+ php_stream *stream,
+ const php_io_terminal_identity *identity,
+ bool is_tty
+)
+{
+ php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(identity);
+ if (shared != NULL) {
+ const char *seam = php_io_terminal_get_test_seam();
+ if (seam != NULL && strcmp(seam, "fail_reapply") == 0) {
+ return NULL;
+ }
+ if (is_tty) {
+ struct termios current_mode;
+ if (tcgetattr(fd, ¤t_mode) != 0) {
+ return NULL;
+ }
+ struct termios raw = current_mode;
+ php_io_terminal_make_raw_mode(&raw);
+ if (php_io_terminal_termios_needs_raw_mode(¤t_mode, &raw)) {
+ if (tcsetattr(fd, TCSANOW, &raw) != 0) {
+ return NULL;
+ }
+ }
+ }
+ shared->lease_count++;
+ return shared;
+ }
+
+ if (!is_tty) {
+ shared = emalloc(sizeof(*shared));
+ shared->identity = *identity;
+ shared->restore_stream = PHP_IO_TERMINAL_INVALID_NATIVE_STREAM;
+ memset(&shared->saved_mode, 0, sizeof(shared->saved_mode));
+ shared->lease_count = 1;
+ shared->refcount = 1;
+ shared->owner_pid = getpid();
+ shared->is_non_tty = true;
+ shared->is_restored = false;
+ shared->prev = NULL;
+ shared->next = php_io_terminal_active_shared_modes;
+ if (php_io_terminal_active_shared_modes != NULL) {
+ php_io_terminal_active_shared_modes->prev = shared;
+ }
+ php_io_terminal_active_shared_modes = shared;
+ return shared;
+ }
+
+ struct termios mode;
+ if (!php_io_terminal_native_stream_is_valid(fd) || isatty(fd) != 1 || tcgetattr(fd, &mode) != 0) {
+ return NULL;
+ }
+
+ 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);
+
+ const char *seam = php_io_terminal_get_test_seam();
+ if ((seam != NULL && strcmp(seam, "fail_first_acquire") == 0) || tcsetattr(fd, TCSANOW, &raw_mode) != 0) {
+ php_io_terminal_close_native_stream(restore_stream);
+ return NULL;
+ }
+
+ struct termios actual_mode;
+ if ((seam != NULL && strcmp(seam, "fail_postcondition") == 0) ||
+ (tcgetattr(fd, &actual_mode) == 0 && (actual_mode.c_lflag & (ECHO | ICANON)) != 0)) {
+ /* Postcondition failure: roll back to original saved mode before closing restore stream */
+ if (tcsetattr(fd, TCSANOW, &mode) != 0) {
+ /* Rollback failed: preserve restoration state so cleanup or retry can restore it */
+ shared = emalloc(sizeof(*shared));
+ shared->identity = *identity;
+ shared->restore_stream = restore_stream;
+ shared->saved_mode = mode;
+ shared->lease_count = 0;
+ shared->refcount = 1;
+ shared->owner_pid = getpid();
+ shared->is_non_tty = false;
+ shared->is_restored = false;
+ shared->prev = NULL;
+ shared->next = php_io_terminal_active_shared_modes;
+ if (php_io_terminal_active_shared_modes != NULL) {
+ php_io_terminal_active_shared_modes->prev = shared;
+ }
+ php_io_terminal_active_shared_modes = shared;
+ return NULL;
+ }
+ php_io_terminal_close_native_stream(restore_stream);
+ return NULL;
+ }
+
+ shared = emalloc(sizeof(*shared));
+ shared->identity = *identity;
+ shared->restore_stream = restore_stream;
+ shared->saved_mode = mode;
+ shared->lease_count = 1;
+ shared->refcount = 1;
+ shared->owner_pid = getpid();
+ shared->is_non_tty = false;
+ shared->is_restored = false;
+ shared->prev = NULL;
+ shared->next = php_io_terminal_active_shared_modes;
+ if (php_io_terminal_active_shared_modes != NULL) {
+ 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;
+}
+#endif
+
+/* Common byte-decoding utilities (used on POSIX and for non-console streams on Windows) */
+
+static size_t php_io_terminal_utf8_sequence_len(unsigned char byte)
+{
+ 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;
+}
+
+#ifndef PHP_WIN32
+static bool php_io_terminal_timespec_subtract_elapsed(struct timespec *remaining, zend_hrtime_t elapsed_ns)
+{
+ uint64_t elapsed_seconds = elapsed_ns / ZEND_NANO_IN_SEC;
+ 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)
+{
+ struct stat st;
+ if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode)) {
+ return NULL;
+ }
+
+ php_poll_ctx *poll_ctx = php_poll_create(PHP_POLL_BACKEND_AUTO, 0);
+
+ if (poll_ctx == NULL) {
+ 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;
+}
+#endif
+
+static int php_io_terminal_read_byte(
+ php_io_terminal_native_stream fd,
+ php_stream *stream,
+ void *poll_ctx_ptr,
+ unsigned char *byte,
+ const void *timeout_ptr,
+ bool allow_resize,
+ const void *generation_ptr
+)
+{
+ 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;
+ }
+
+#ifdef PHP_WIN32
+ (void) poll_ctx_ptr;
+ (void) allow_resize;
+ (void) generation_ptr;
+
+ HANDLE h = fd;
+ if ((h == INVALID_HANDLE_VALUE || h == NULL) && stream != NULL) {
+ h = php_io_terminal_native_stream_from_php_stream(stream);
+ }
+
+ if (h != INVALID_HANDLE_VALUE && h != NULL) {
+ int opt = 0;
+ int optlen = sizeof(opt);
+ if (getsockopt((SOCKET) h, SOL_SOCKET, SO_TYPE, (char *) &opt, &optlen) == 0) {
+ if (timeout_ptr != NULL) {
+ const struct timespec *to = (const struct timespec *) timeout_ptr;
+ uint64_t ms = (uint64_t) to->tv_sec * 1000 + (to->tv_nsec + 999999) / 1000000;
+ DWORD wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms;
+ fd_set rset;
+ FD_ZERO(&rset);
+ FD_SET((SOCKET) h, &rset);
+ struct timeval tv;
+ tv.tv_sec = (long) (wait_ms / 1000);
+ tv.tv_usec = (long) ((wait_ms % 1000) * 1000);
+ int sel_res = select(0, &rset, NULL, NULL, &tv);
+ if (sel_res == 0) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ if (sel_res < 0) {
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+ }
+ if (stream != NULL) {
+ size_t read_bytes = php_stream_read(stream, (char *) byte, 1);
+ if (read_bytes == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR;
+ }
+ int recvd = recv((SOCKET) h, (char *) byte, 1, 0);
+ if (recvd == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ return recvd == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR;
+ }
+ }
+
+ if (h != INVALID_HANDLE_VALUE && h != NULL && GetFileType(h) == FILE_TYPE_PIPE) {
+ DWORD bytes_avail = 0;
+ DWORD wait_ms = 0;
+ bool has_timeout = false;
+ if (timeout_ptr != NULL) {
+ const struct timespec *to = (const struct timespec *) timeout_ptr;
+ has_timeout = true;
+ uint64_t ms = (uint64_t) to->tv_sec * 1000 + (to->tv_nsec + 999999) / 1000000;
+ wait_ms = ms >= (DWORD) PHP_IO_TERMINAL_MAX_WAIT_MS ? PHP_IO_TERMINAL_MAX_WAIT_MS : (DWORD) ms;
+ }
+
+ if (PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL)) {
+ zend_hrtime_t start_time = zend_hrtime();
+ while (true) {
+ if (bytes_avail > 0) {
+ break;
+ }
+ if (has_timeout) {
+ if (wait_ms == 0) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ zend_hrtime_t elapsed_ms = (zend_hrtime() - start_time) / 1000000;
+ if (elapsed_ms >= wait_ms) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ DWORD sleep_ms = (wait_ms - (DWORD) elapsed_ms > 5) ? 5 : (DWORD)(wait_ms - elapsed_ms);
+ Sleep(sleep_ms);
+ } else {
+ Sleep(10);
+ }
+ if (!PeekNamedPipe(h, NULL, 0, NULL, &bytes_avail, NULL)) {
+ DWORD err = GetLastError();
+ if (err == ERROR_BROKEN_PIPE || err == ERROR_PIPE_NOT_CONNECTED) {
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+ }
+ } else {
+ DWORD err = GetLastError();
+ if (err == ERROR_BROKEN_PIPE || err == ERROR_PIPE_NOT_CONNECTED) {
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+ if (err == ERROR_INVALID_FUNCTION) {
+ /* Socket descriptor on Windows returns FILE_TYPE_PIPE: use select() */
+ fd_set rset;
+ FD_ZERO(&rset);
+ FD_SET((SOCKET) h, &rset);
+ struct timeval tv;
+ struct timeval *tv_ptr = NULL;
+ if (has_timeout) {
+ tv.tv_sec = (long) (wait_ms / 1000);
+ tv.tv_usec = (long) ((wait_ms % 1000) * 1000);
+ tv_ptr = &tv;
+ }
+ int sel_res = select(0, &rset, NULL, NULL, tv_ptr);
+ if (sel_res == 0) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ if (sel_res < 0) {
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+ } else {
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+ }
+ }
+
+ if (stream != NULL) {
+ size_t read_bytes = php_stream_read(stream, (char *) byte, 1);
+ if (read_bytes == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ if (timeout_ptr != NULL && !php_stream_eof(stream)) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ return php_stream_eof(stream) ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ if (fd != INVALID_HANDLE_VALUE && fd != NULL) {
+ DWORD bytes_read = 0;
+ if (ReadFile(fd, byte, 1, &bytes_read, NULL) && bytes_read == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ if (timeout_ptr != NULL) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+
+ return PHP_IO_TERMINAL_READ_ERROR;
+#else
+ php_poll_ctx *poll_ctx = (php_poll_ctx *) poll_ctx_ptr;
+ const struct timespec *timeout = (const struct timespec *) timeout_ptr;
+ const sig_atomic_t *generation = (const sig_atomic_t *) generation_ptr;
+
+ if (poll_ctx == NULL) {
+ if (stream != NULL) {
+ int old_flags = stream->flags;
+ stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS;
+ size_t read_bytes = php_stream_read(stream, (char *) byte, 1);
+ stream->flags = old_flags;
+ if (read_bytes == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ if (php_stream_eof(stream)) {
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+ if (timeout != NULL) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+
+ if (fd >= 0) {
+ ssize_t bytes_read = read(fd, byte, 1);
+ if (bytes_read == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ if (timeout != NULL) {
+ return PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ return bytes_read == 0 ? PHP_IO_TERMINAL_READ_EOF : PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ 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;
+ }
+
+ if (stream != NULL) {
+ int old_flags = stream->flags;
+ stream->flags |= PHP_STREAM_FLAG_SUPPRESS_ERRORS;
+ size_t n = php_stream_read(stream, (char *) byte, 1);
+ stream->flags = old_flags;
+ if (n == 1) {
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+ }
+ if (php_stream_eof(stream)) {
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ ssize_t bytes_read = read(fd, byte, 1);
+ if (bytes_read == 0) {
+ return PHP_IO_TERMINAL_READ_EOF;
+ }
+ if (bytes_read < 0) {
+ return PHP_IO_TERMINAL_READ_ERROR;
+ }
+
+ return PHP_IO_TERMINAL_READ_SUCCESS;
+#endif
+}
+
+static zend_string *php_io_terminal_finish_utf8_sequence(
+ php_io_terminal_native_stream fd,
+ php_stream *stream,
+ void *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) {
+#ifndef PHP_WIN32
+ if (fd >= 0 && php_io_terminal_native_stream_is_tty(fd)) {
+ zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0);
+ }
+#endif
+ return NULL;
+ }
+ if (result != PHP_IO_TERMINAL_READ_SUCCESS) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ 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(
+ php_io_terminal_native_stream fd,
+ php_stream *stream,
+ void *poll_ctx,
+ const void *sequence_timeout_ptr,
+ bool has_overall_deadline,
+ zend_hrtime_t overall_deadline_ns
+)
+{
+ unsigned char seq[32];
+ size_t seq_len = 0;
+ seq[seq_len++] = 0x1b;
+
+ const struct timespec *sequence_timeout = (const struct timespec *) sequence_timeout_ptr;
+ zend_hrtime_t now = zend_hrtime();
+ zend_hrtime_t seq_timeout_ns = php_io_terminal_timespec_to_ns(sequence_timeout);
+ zend_hrtime_t deadline_ns = php_io_terminal_saturating_add_deadline(now, seq_timeout_ns);
+ 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 || read_result == PHP_IO_TERMINAL_READ_EOF) {
+ return ZSTR_INIT_LITERAL("escape", false);
+ }
+ if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ return NULL;
+ }
+ 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);
+ read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &seq[seq_len], &remaining_ts, false, NULL);
+ if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) {
+ if (read_result == PHP_IO_TERMINAL_READ_EOF) {
+ break;
+ }
+ if (read_result == PHP_IO_TERMINAL_READ_ERROR) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ return NULL;
+ }
+ break;
+ }
+
+ unsigned char next = seq[seq_len++];
+ 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);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[Z")) return ZSTR_INIT_LITERAL("shifttab", false);
+ } else if (seq_len == 4) {
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[1~")) return ZSTR_INIT_LITERAL("home", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[4~")) return ZSTR_INIT_LITERAL("end", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[2~")) return ZSTR_INIT_LITERAL("insert", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[3~")) return ZSTR_INIT_LITERAL("delete", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[5~")) return ZSTR_INIT_LITERAL("pageup", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[6~")) return ZSTR_INIT_LITERAL("pagedown", false);
+ } else if (seq_len == 5) {
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[11~")) return ZSTR_INIT_LITERAL("f1", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[12~")) return ZSTR_INIT_LITERAL("f2", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[13~")) return ZSTR_INIT_LITERAL("f3", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[14~")) return ZSTR_INIT_LITERAL("f4", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[15~")) return ZSTR_INIT_LITERAL("f5", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[17~")) return ZSTR_INIT_LITERAL("f6", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[18~")) return ZSTR_INIT_LITERAL("f7", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[19~")) return ZSTR_INIT_LITERAL("f8", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[20~")) return ZSTR_INIT_LITERAL("f9", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[21~")) return ZSTR_INIT_LITERAL("f10", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[23~")) return ZSTR_INIT_LITERAL("f11", false);
+ if (PHP_IO_TERMINAL_CSI_IS("\x1b[24~")) return ZSTR_INIT_LITERAL("f12", false);
+ }
+
+#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);
+ } else {
+ read_result = PHP_IO_TERMINAL_READ_TIMEOUT;
+ }
+ if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) {
+ seq_len++;
+ switch (seq[2]) {
+ case 'P': return ZSTR_INIT_LITERAL("f1", false);
+ case 'Q': return ZSTR_INIT_LITERAL("f2", false);
+ case 'R': return ZSTR_INIT_LITERAL("f3", false);
+ case 'S': return ZSTR_INIT_LITERAL("f4", false);
+ case 'H': return ZSTR_INIT_LITERAL("home", false);
+ case 'F': return ZSTR_INIT_LITERAL("end", false);
+ }
+ } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ return NULL;
+ }
+ }
+
+ return zend_string_init((const char *) seq, seq_len, false);
+}
+
+static zend_string *php_io_terminal_key_from_utf8_sequence(
+ php_io_terminal_native_stream fd,
+ php_stream *stream,
+ void *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(
+ php_io_terminal_native_stream fd,
+ php_stream *stream,
+ void *poll_ctx,
+ unsigned char key,
+ const void *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_object *php_io_terminal_key_enum_from_string(zend_string *key)
+{
+ if (zend_string_equals_literal(key, "up")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Up");
+ }
+ if (zend_string_equals_literal(key, "down")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Down");
+ }
+ if (zend_string_equals_literal(key, "right")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Right");
+ }
+ if (zend_string_equals_literal(key, "left")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Left");
+ }
+ if (zend_string_equals_literal(key, "enter")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Enter");
+ }
+ if (zend_string_equals_literal(key, "backspace")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Backspace");
+ }
+ if (zend_string_equals_literal(key, "escape")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Escape");
+ }
+ if (zend_string_equals_literal(key, "tab")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Tab");
+ }
+ if (zend_string_equals_literal(key, "home")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Home");
+ }
+ if (zend_string_equals_literal(key, "end")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "End");
+ }
+ if (zend_string_equals_literal(key, "delete")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Delete");
+ }
+ if (zend_string_equals_literal(key, "insert")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Insert");
+ }
+ if (zend_string_equals_literal(key, "pageup")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "PageUp");
+ }
+ if (zend_string_equals_literal(key, "pagedown")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "PageDown");
+ }
+ if (zend_string_equals_literal(key, "resize")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "Resize");
+ }
+ if (zend_string_equals_literal(key, "shifttab")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "ShiftTab");
+ }
+ if (zend_string_equals_literal(key, "f1")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F1");
+ }
+ if (zend_string_equals_literal(key, "f2")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F2");
+ }
+ if (zend_string_equals_literal(key, "f3")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F3");
+ }
+ if (zend_string_equals_literal(key, "f4")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F4");
+ }
+ if (zend_string_equals_literal(key, "f5")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F5");
+ }
+ if (zend_string_equals_literal(key, "f6")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F6");
+ }
+ if (zend_string_equals_literal(key, "f7")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F7");
+ }
+ if (zend_string_equals_literal(key, "f8")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F8");
+ }
+ if (zend_string_equals_literal(key, "f9")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F9");
+ }
+ if (zend_string_equals_literal(key, "f10")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F10");
+ }
+ if (zend_string_equals_literal(key, "f11")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F11");
+ }
+ if (zend_string_equals_literal(key, "f12")) {
+ return zend_enum_get_case_cstr(php_io_terminal_key_ce, "F12");
+ }
+ return NULL;
+}
+
+#ifndef PHP_WIN32
+static zend_string *php_io_terminal_read_stream_key(
+ php_io_terminal_native_stream input,
+ php_stream *stream,
+ const struct timespec *timeout,
+ const struct timespec *sequence_timeout,
+ php_io_terminal_object *intern
+)
+{
+ php_io_terminal_utf8_pending *pending = &intern->pending_utf8;
+ int fd = input;
+ bool is_tty = (fd >= 0 && isatty(fd) == 1);
+ struct termios mode;
+ struct termios raw_mode;
+ bool mode_changed = false;
+ php_poll_ctx *poll_ctx = NULL;
+
+ if (is_tty) {
+ if (tcgetattr(fd, &mode) != 0) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to get terminal mode", 0);
+ return NULL;
+ }
+ raw_mode = mode;
+ php_io_terminal_make_raw_mode(&raw_mode);
+ if (php_io_terminal_termios_needs_raw_mode(&mode, &raw_mode)) {
+ if (tcsetattr(fd, TCSANOW, &raw_mode) == 0) {
+ mode_changed = true;
+ } else {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0);
+ return NULL;
+ }
+ }
+ }
+
+ if (fd >= 0) {
+ poll_ctx = php_io_terminal_create_poll_context(fd);
+ }
+
+ sig_atomic_t resize_generation = 0;
+ bool resize_handler_installed = false;
+#if defined(SIGWINCH)
+ if (is_tty) {
+ resize_handler_installed = php_io_terminal_install_resize_handler(&resize_generation);
+ }
+#endif
+
+ bool has_overall_deadline = (timeout != NULL);
+ zend_hrtime_t overall_deadline_ns = 0;
+ 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 {
+ while (1) {
+ unsigned char key;
+ int read_result = php_io_terminal_read_byte(fd, stream, poll_ctx, &key, timeout, true,
+ resize_handler_installed ? &resize_generation : NULL);
+ if (read_result == PHP_IO_TERMINAL_READ_RESIZE) {
+ result = ZSTR_INIT_LITERAL("resize", false);
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) {
+ if (intern->last_secret_ended_in_cr) {
+ intern->last_secret_ended_in_cr = false;
+ if (key == '\n') {
+ continue;
+ }
+ }
+ result = php_io_terminal_key_from_byte(
+ fd, stream, poll_ctx, key, sequence_timeout, pending,
+ has_overall_deadline, overall_deadline_ns
+ );
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_EOF) {
+ zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0);
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ result = NULL;
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ break;
+ }
+ }
+ }
+
+ 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);
+ }
+ if (poll_ctx != NULL) {
+ php_poll_destroy(poll_ctx);
+ }
+ if (!EG(exception)) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0);
+ }
+ return NULL;
+ }
+
+#if defined(SIGWINCH)
+ if (resize_handler_installed) {
+ php_io_terminal_restore_resize_handler();
+ }
+#endif
+
+ if (poll_ctx != NULL) {
+ 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,
+ php_io_terminal_object *intern,
+ bool *timed_out
+)
+{
+ *timed_out = false;
+ int fd = input;
+ bool is_tty = (fd >= 0 && isatty(fd) == 1);
+ struct termios mode;
+ struct termios raw_mode;
+ bool mode_changed = false;
+ php_poll_ctx *poll_ctx = NULL;
+
+ if (is_tty) {
+ if (tcgetattr(fd, &mode) != 0) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to get terminal mode", 0);
+ return NULL;
+ }
+ raw_mode = mode;
+ php_io_terminal_make_raw_mode(&raw_mode);
+ if (php_io_terminal_termios_needs_raw_mode(&mode, &raw_mode)) {
+ if (tcsetattr(fd, TCSANOW, &raw_mode) != 0) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to set terminal raw mode", 0);
+ return NULL;
+ }
+ mode_changed = true;
+ }
+ }
+
+ if (fd >= 0) {
+ poll_ctx = php_io_terminal_create_poll_context(fd);
+ }
+
+ bool has_overall_deadline = (timeout != NULL);
+ zend_hrtime_t overall_deadline_ns = 0;
+ bool is_positive_timeout = false;
+ if (has_overall_deadline) {
+ overall_deadline_ns = php_io_terminal_saturating_add_deadline(
+ zend_hrtime(),
+ php_io_terminal_timespec_to_ns(timeout)
+ );
+ is_positive_timeout = (timeout->tv_sec > 0 || timeout->tv_nsec > 0);
+ }
+
+ smart_str secret = {0};
+ bool success = false;
+ unsigned char key = 0;
+
+ /* If pending UTF-8 bytes exist from previous readKey, process them first */
+ if (intern != NULL && intern->pending_utf8.length > 0) {
+ unsigned char terminating_key = 0;
+ bool has_terminating_key = false;
+
+ while (intern->pending_utf8.length < intern->pending_utf8.expected) {
+ unsigned char next_b;
+ struct timespec cur_ts;
+ const struct timespec *cur_ts_ptr = NULL;
+ if (has_overall_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= overall_deadline_ns) {
+ *timed_out = true;
+ goto restore;
+ }
+ php_io_terminal_ns_to_timespec(overall_deadline_ns - now, &cur_ts);
+ cur_ts_ptr = &cur_ts;
+ }
+ int r = php_io_terminal_read_byte(fd, stream, poll_ctx, &next_b, cur_ts_ptr, false, NULL);
+ if (r == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ *timed_out = true;
+ goto restore;
+ }
+ if (r != PHP_IO_TERMINAL_READ_SUCCESS) {
+ *timed_out = false;
+ goto restore;
+ }
+ if ((next_b & 0xc0) == 0x80) {
+ intern->pending_utf8.bytes[intern->pending_utf8.length++] = next_b;
+ } else {
+ terminating_key = next_b;
+ has_terminating_key = true;
+ break;
+ }
+ }
+
+ smart_str_appendl(&secret, (char *) intern->pending_utf8.bytes, intern->pending_utf8.length);
+ memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8));
+
+ if (has_terminating_key) {
+ key = terminating_key;
+ goto process_key;
+ }
+ }
+
+ while (true) {
+ struct timespec current_timeout;
+ const struct timespec *current_timeout_ptr = NULL;
+
+ if (has_overall_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= overall_deadline_ns) {
+ if (is_positive_timeout) {
+ *timed_out = true;
+ goto restore;
+ }
+ current_timeout.tv_sec = 0;
+ current_timeout.tv_nsec = 0;
+ } else {
+ 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:
+ if (intern != NULL && intern->last_secret_ended_in_cr) {
+ intern->last_secret_ended_in_cr = false;
+ if (key == '\n') {
+ continue;
+ }
+ }
+
+ switch (key) {
+ case '\r':
+ case '\n':
+ if (key == '\r') {
+ /* CRLF is one terminator. Consume the LF now only if it is already
+ * visible in the PHP read buffer (no read, no unread). Otherwise defer:
+ * the next reader on this Terminal skips a single leading LF. A byte is
+ * never read ahead here, so nothing has to be pushed back. */
+ if (stream != NULL && stream->writepos > stream->readpos && stream->readbuf != NULL && stream->readbuf[stream->readpos] == '\n') {
+ stream->readpos++;
+ } else if (intern != NULL) {
+ intern->last_secret_ended_in_cr = true;
+ }
+ }
+ success = true;
+ goto restore;
+ case 0x7f:
+ 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) {
+ *timed_out = false;
+ goto restore;
+ }
+
+ if (next == 0x03 || next == 0x04 || next == 0x1b || next == '\r' || next == '\n') {
+ *timed_out = false;
+ goto restore;
+ }
+
+ if (next == '[') {
+ while (true) {
+ if (has_overall_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= overall_deadline_ns) {
+ if (is_positive_timeout) {
+ *timed_out = true;
+ goto restore;
+ }
+ step_timeout.tv_sec = 0;
+ step_timeout.tv_nsec = 0;
+ } else {
+ 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) {
+ break;
+ }
+ *timed_out = false;
+ goto restore;
+ }
+ if (has_overall_deadline && is_positive_timeout && zend_hrtime() >= overall_deadline_ns) {
+ *timed_out = true;
+ goto restore;
+ }
+ if (csi_byte == 0x03 || csi_byte == 0x04 || csi_byte == 0x1b || csi_byte == '\r' || csi_byte == '\n') {
+ *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) {
+ if (is_positive_timeout) {
+ *timed_out = true;
+ goto restore;
+ }
+ step_timeout.tv_sec = 0;
+ step_timeout.tv_nsec = 0;
+ } else {
+ zend_hrtime_t remaining_ns = overall_deadline_ns - now;
+ zend_hrtime_t step_ns = remaining_ns < 25000000ULL ? remaining_ns : 25000000ULL;
+ php_io_terminal_ns_to_timespec(step_ns, &step_timeout);
+ }
+ 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) {
+ break;
+ }
+ *timed_out = false;
+ goto restore;
+ }
+ if (has_overall_deadline && is_positive_timeout && zend_hrtime() >= overall_deadline_ns) {
+ *timed_out = true;
+ goto restore;
+ }
+ if (ss3_byte == 0x03 || ss3_byte == 0x04 || ss3_byte == 0x1b || ss3_byte == '\r' || ss3_byte == '\n') {
+ *timed_out = false;
+ goto restore;
+ }
+ break;
+ }
+
+ *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;
+ if (!EG(exception)) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to restore terminal mode", 0);
+ }
+ }
+
+ if (poll_ctx != NULL) {
+ php_poll_destroy(poll_ctx);
+ }
+
+ if (success) {
+ return smart_str_extract(&secret);
+ }
+
+ smart_str_free(&secret);
+ return NULL;
+}
+#endif /* !PHP_WIN32 */
+
+/* Io\Terminal\TerminalSize methods */
+PHP_METHOD(Io_Terminal_TerminalSize, __construct)
+{
+ 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\ModeToken methods */
+PHP_METHOD(Io_Terminal_ModeToken, __construct)
+{
+ zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\ModeToken");
+}
+
+/* Io\Terminal\Terminal methods */
+PHP_METHOD(Io_Terminal_Terminal, __construct)
+{
+ zend_throw_error(NULL, "Cannot directly construct Io\\Terminal\\Terminal");
+}
+
+PHP_METHOD(Io_Terminal_Terminal, fromStdio)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ object_init_ex(return_value, php_io_terminal_terminal_ce);
+ php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(return_value);
+
+ php_io_terminal_stream_target target;
+ if (php_io_terminal_stream_target_init(NULL, true, &target)) {
+ bool is_tty = php_io_terminal_native_stream_is_tty(target.native_stream);
+ if (is_tty) {
+ intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity);
+ } else {
+ intern->identity.is_non_tty_stream = true;
+ intern->identity.php_stream_ptr = target.php_stream;
+ if (target.stream_resource != NULL && Z_TYPE_P(target.stream_resource) == IS_RESOURCE) {
+ intern->identity.stream_res_id = Z_RES_HANDLE_P(target.stream_resource);
+ }
+ intern->has_identity = true;
+ }
+ }
+}
+
+PHP_METHOD(Io_Terminal_Terminal, 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_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);
+ }
+
+ bool is_tty = php_io_terminal_native_stream_is_tty(target.native_stream);
+ if (is_tty) {
+ intern->has_identity = php_io_terminal_get_identity(target.native_stream, &intern->identity);
+ } else {
+ memset(&intern->identity, 0, sizeof(intern->identity));
+ intern->identity.is_non_tty_stream = true;
+ intern->identity.php_stream_ptr = target.php_stream;
+ if (target.stream_resource != NULL && Z_TYPE_P(target.stream_resource) == IS_RESOURCE) {
+ intern->identity.stream_res_id = Z_RES_HANDLE_P(target.stream_resource);
+ }
+ intern->has_identity = true;
+ }
+}
+
+PHP_METHOD(Io_Terminal_Terminal, 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_Terminal, 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();
+ }
+
+ bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream);
+ php_io_terminal_identity identity;
+ memset(&identity, 0, sizeof(identity));
+
+ if (is_tty) {
+ if (!php_io_terminal_get_identity(stream.native_stream, &identity)) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0);
+ RETURN_THROWS();
+ }
+ } else {
+ identity.is_non_tty_stream = true;
+ identity.php_stream_ptr = stream.php_stream;
+ if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) {
+ identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource);
+ }
+ }
+
+ intern->identity = identity;
+ intern->has_identity = true;
+
+ php_io_terminal_shared_mode *shared = php_io_terminal_acquire_raw_mode(
+ stream.native_stream,
+ stream.php_stream,
+ &identity,
+ is_tty
+ );
+ if (shared == NULL) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to enable terminal raw mode", 0);
+ RETURN_THROWS();
+ }
+
+ php_io_terminal_create_mode_token(return_value, shared);
+}
+
+PHP_METHOD(Io_Terminal_Terminal, restoreMode)
+{
+ zval *mode_token_zv;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJECT_OF_CLASS(mode_token_zv, php_io_terminal_mode_token_ce)
+ ZEND_PARSE_PARAMETERS_END();
+
+ php_io_terminal_object *intern = PHP_IO_TERMINAL_OBJ_FROM_ZV(ZEND_THIS);
+ php_io_terminal_mode_token_object *mode = PHP_IO_TERMINAL_MODE_TOKEN_OBJ_FROM_ZV(mode_token_zv);
+
+ if (!mode->valid || mode->shared == NULL) {
+ zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal");
+ RETURN_THROWS();
+ }
+
+ if (mode->owner_pid != getpid()) {
+ zend_argument_value_error(1, "must be an active terminal mode token belonging to this terminal");
+ RETURN_THROWS();
+ }
+
+ bool matches = false;
+ php_io_terminal_stream_target stream;
+ php_io_terminal_identity current_identity;
+
+ if (php_io_terminal_stream_target_init_ex(&intern->input_stream_val, true, true, &stream)) {
+ bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream);
+ if (is_tty && php_io_terminal_get_identity(stream.native_stream, ¤t_identity)) {
+ matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity);
+ } else if (!is_tty) {
+ memset(¤t_identity, 0, sizeof(current_identity));
+ current_identity.is_non_tty_stream = true;
+ current_identity.php_stream_ptr = stream.php_stream;
+ if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) {
+ current_identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource);
+ }
+ matches = php_io_terminal_identities_match(¤t_identity, &mode->shared->identity);
+ }
+ } 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();
+ }
+}
+
+PHP_METHOD(Io_Terminal_Terminal, 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;
+ }
+ }
+
+ DWORD console_mode;
+ bool is_console = (stream.native_stream != INVALID_HANDLE_VALUE && stream.native_stream != NULL && GetConsoleMode(stream.native_stream, &console_mode) != 0);
+ zend_string *key = NULL;
+
+ if (is_console) {
+ key = php_io_terminal_read_console_key(
+ stream.native_stream,
+ wait_ms,
+ &intern->pending_high_surrogate,
+ &intern->pending_key,
+ &intern->pending_key_high_surrogate
+ );
+ } else {
+ /* Non-console stream on Windows: use byte decoding */
+ struct timespec win_timeout;
+ struct timespec win_seq_timeout = {0, PHP_IO_TERMINAL_SEQUENCE_TIMEOUT_MS * 1000000L};
+ const struct timespec *win_timeout_ptr = NULL;
+ bool win_is_non_blocking = false;
+
+ if (timeout_duration != NULL) {
+ win_is_non_blocking = (timeout_duration->duration.seconds == 0 && timeout_duration->duration.nanoseconds == 0);
+ win_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds);
+ win_timeout.tv_nsec = timeout_duration->duration.nanoseconds;
+ win_timeout_ptr = &win_timeout;
+ }
+
+ if (win_is_non_blocking) {
+ win_seq_timeout.tv_sec = 0;
+ win_seq_timeout.tv_nsec = 0;
+ } else if (seq_timeout_duration != NULL) {
+ win_seq_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(seq_timeout_duration->duration.seconds);
+ win_seq_timeout.tv_nsec = seq_timeout_duration->duration.nanoseconds;
+ }
+
+ bool has_overall_deadline = (win_timeout_ptr != NULL);
+ zend_hrtime_t overall_deadline_ns = 0;
+ if (has_overall_deadline) {
+ overall_deadline_ns = php_io_terminal_saturating_add_deadline(
+ zend_hrtime(),
+ php_io_terminal_timespec_to_ns(win_timeout_ptr)
+ );
+ }
+
+ if (intern->pending_utf8.length > 0) {
+ key = php_io_terminal_finish_utf8_sequence(
+ stream.native_stream, stream.php_stream, NULL, &intern->pending_utf8,
+ has_overall_deadline, overall_deadline_ns
+ );
+ } else {
+ while (1) {
+ unsigned char byte;
+ int read_result = php_io_terminal_read_byte(
+ stream.native_stream, stream.php_stream, NULL, &byte, win_timeout_ptr, false, NULL
+ );
+ if (read_result == PHP_IO_TERMINAL_READ_SUCCESS) {
+ if (intern->last_secret_ended_in_cr) {
+ intern->last_secret_ended_in_cr = false;
+ if (byte == '\n') {
+ continue;
+ }
+ }
+ key = php_io_terminal_key_from_byte(
+ stream.native_stream, stream.php_stream, NULL, byte, &win_seq_timeout, &intern->pending_utf8,
+ has_overall_deadline, overall_deadline_ns
+ );
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_EOF) {
+ zend_throw_exception(php_io_terminal_exception_ce, "End of file reached on terminal input stream", 0);
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ key = NULL;
+ break;
+ } else if (read_result == PHP_IO_TERMINAL_READ_ERROR) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Failed to read from terminal input stream", 0);
+ break;
+ }
+ }
+ }
+ }
+#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
+ );
+#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;
+ }
+ bool remainder_is_lf = (line_len == 1 && buf[0] == '\n');
+ if (line_len >= 2 && buf[line_len - 2] == '\r' && buf[line_len - 1] == '\n') {
+ line_len -= 2;
+ } else if (line_len >= 1 && buf[line_len - 1] == '\n') {
+ line_len -= 1;
+ }
+ buf[line_len] = '\0';
+ if (remainder_is_lf && intern != NULL && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') {
+ intern->pending_utf8.length--;
+ }
+ zend_string *res = zend_string_init(buf, line_len, 0);
+ efree(buf);
+ return res;
+ }
+
+ 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;
+ }
+ bool remainder_is_lf = (line_len == 1 && buf[0] == '\n');
+ if (line_len >= 2 && buf[line_len - 2] == '\r' && buf[line_len - 1] == '\n') {
+ line_len -= 2;
+ } else if (line_len >= 1 && buf[line_len - 1] == '\n') {
+ line_len -= 1;
+ }
+ buf[line_len] = '\0';
+ if (remainder_is_lf && intern != NULL && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') {
+ intern->pending_utf8.length--;
+ }
+ zend_string *res = zend_string_init(buf, line_len, 0);
+ efree(buf);
+ return res;
+ }
+
+ /* 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;
+ }
+ 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) {
+ 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_Terminal, 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();
+ }
+
+ bool is_tty = php_io_terminal_native_stream_is_tty(stream.native_stream);
+ if (!intern->has_identity) {
+ if (is_tty) {
+ intern->has_identity = php_io_terminal_get_identity(stream.native_stream, &intern->identity);
+ } else {
+ memset(&intern->identity, 0, sizeof(intern->identity));
+ intern->identity.is_non_tty_stream = true;
+ intern->identity.php_stream_ptr = stream.php_stream;
+ if (stream.stream_resource != NULL && Z_TYPE_P(stream.stream_resource) == IS_RESOURCE) {
+ intern->identity.stream_res_id = Z_RES_HANDLE_P(stream.stream_resource);
+ }
+ intern->has_identity = true;
+ }
+ }
+
+ if (intern->has_identity && is_tty) {
+ php_io_terminal_shared_mode *shared = php_io_terminal_find_shared_mode(&intern->identity);
+ if (shared != NULL && !shared->is_non_tty && !shared->is_restored && shared->owner_pid == getpid()) {
+ zend_throw_exception(php_io_terminal_exception_ce, "Cannot read a line while raw mode is active for this terminal", 0);
+ RETURN_THROWS();
+ }
+ }
+
+#ifdef PHP_WIN32
+ if (intern->last_secret_ended_in_cr) {
+ intern->last_secret_ended_in_cr = false;
+ if (stream.php_stream != NULL) {
+ if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) {
+ if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') {
+ stream.php_stream->readpos++;
+ }
+ } else {
+ int ch = php_stream_getc(stream.php_stream);
+ if (ch != '\n' && ch != EOF) {
+ intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch;
+ }
+ }
+ }
+ }
+
+ zend_string *line = php_io_terminal_read_line_windows(stream.native_stream, stream.php_stream, intern);
+ if (EG(exception)) {
+ if (line != NULL) {
+ zend_string_release(line);
+ }
+ RETURN_THROWS();
+ }
+ if (line == NULL) {
+ if (intern->pending_utf8.length > 0) {
+ zend_string *res = zend_string_init((char *) intern->pending_utf8.bytes, intern->pending_utf8.length, 0);
+ intern->pending_utf8.length = 0;
+ intern->pending_utf8.expected = 0;
+ RETURN_STR(res);
+ }
+ RETURN_NULL();
+ }
+
+ if (intern->pending_utf8.length > 0) {
+ size_t total_len = intern->pending_utf8.length + ZSTR_LEN(line);
+ zend_string *res = zend_string_alloc(total_len, 0);
+ memcpy(ZSTR_VAL(res), intern->pending_utf8.bytes, intern->pending_utf8.length);
+ memcpy(ZSTR_VAL(res) + intern->pending_utf8.length, ZSTR_VAL(line), ZSTR_LEN(line));
+ ZSTR_VAL(res)[total_len] = '\0';
+ intern->pending_utf8.length = 0;
+ intern->pending_utf8.expected = 0;
+ zend_string_release(line);
+ RETURN_STR(res);
+ }
+ RETURN_STR(line);
+#else
+ if (intern->last_secret_ended_in_cr) {
+ intern->last_secret_ended_in_cr = false;
+ if (stream.php_stream != NULL) {
+ if (stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) {
+ if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') {
+ stream.php_stream->readpos++;
+ }
+ } else {
+ int ch = php_stream_getc(stream.php_stream);
+ if (ch != '\n' && ch != EOF) {
+ intern->pending_utf8.bytes[intern->pending_utf8.length++] = (unsigned char) ch;
+ }
+ }
+ }
+ }
+
+ size_t out_len = 0;
+ bool is_eof = false;
+ char *buf = php_io_terminal_read_line_posix(stream.native_stream, stream.php_stream, &out_len, &is_eof);
+ 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 */
+ bool remainder_is_lf = (out_len == 1 && buf[0] == '\n');
+ if (out_len >= 2 && buf[out_len - 2] == '\r' && buf[out_len - 1] == '\n') {
+ out_len -= 2;
+ } else if (out_len >= 1 && buf[out_len - 1] == '\n') {
+ out_len -= 1;
+ }
+ buf[out_len] = '\0';
+
+ if (remainder_is_lf && intern->pending_utf8.length > 0 && intern->pending_utf8.bytes[intern->pending_utf8.length - 1] == '\r') {
+ intern->pending_utf8.length--;
+ }
+
+ zend_string *res;
+ if (intern->pending_utf8.length > 0) {
+ size_t total_len = intern->pending_utf8.length + out_len;
+ 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_Terminal, 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);
+
+ 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;
+ }
+ }
+
+ DWORD console_mode;
+ bool is_console = (stream.native_stream != INVALID_HANDLE_VALUE && stream.native_stream != NULL && GetConsoleMode(stream.native_stream, &console_mode) != 0);
+ zend_string *secret = NULL;
+
+ if (is_console) {
+ secret = php_io_terminal_read_console_secret(
+ stream.native_stream,
+ &intern->pending_key,
+ &intern->pending_key_high_surrogate,
+ &intern->pending_high_surrogate,
+ wait_ms,
+ has_timeout,
+ &timed_out
+ );
+ } else {
+ /* Non-console stream on Windows */
+ smart_str sec_str = {0};
+ bool success = false;
+ unsigned char byte = 0;
+ struct timespec win_sec_timeout;
+ const struct timespec *win_sec_timeout_ptr = NULL;
+ bool win_has_deadline = false;
+ zend_hrtime_t win_deadline_ns = 0;
+ bool win_is_positive_timeout = false;
+
+ if (timeout_duration != NULL) {
+ win_sec_timeout.tv_sec = php_io_terminal_clamp_duration_seconds(timeout_duration->duration.seconds);
+ win_sec_timeout.tv_nsec = timeout_duration->duration.nanoseconds;
+ win_sec_timeout_ptr = &win_sec_timeout;
+ win_has_deadline = true;
+ win_deadline_ns = php_io_terminal_saturating_add_deadline(
+ zend_hrtime(),
+ php_io_terminal_timespec_to_ns(&win_sec_timeout)
+ );
+ win_is_positive_timeout = (win_sec_timeout.tv_sec > 0 || win_sec_timeout.tv_nsec > 0);
+ }
+
+ /* If pending UTF-8 bytes exist from previous readKey, process them first */
+ if (intern->pending_utf8.length > 0) {
+ unsigned char terminating_key = 0;
+ bool has_terminating_key = false;
+
+ while (intern->pending_utf8.length < intern->pending_utf8.expected) {
+ unsigned char next_b;
+ struct timespec cur_ts;
+ const struct timespec *cur_ts_ptr = NULL;
+ if (win_has_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= win_deadline_ns) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_ts);
+ cur_ts_ptr = &cur_ts;
+ }
+ int r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, cur_ts_ptr, false, NULL);
+ if (r == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ if (r != PHP_IO_TERMINAL_READ_SUCCESS) {
+ timed_out = false;
+ goto win_secret_done;
+ }
+ if ((next_b & 0xc0) == 0x80) {
+ intern->pending_utf8.bytes[intern->pending_utf8.length++] = next_b;
+ } else {
+ terminating_key = next_b;
+ has_terminating_key = true;
+ break;
+ }
+ }
+
+ smart_str_appendl(&sec_str, (char *) intern->pending_utf8.bytes, intern->pending_utf8.length);
+ memset(&intern->pending_utf8, 0, sizeof(intern->pending_utf8));
+
+ if (has_terminating_key) {
+ byte = terminating_key;
+ goto win_process_key;
+ }
+ }
+
+ while (true) {
+ struct timespec cur_to;
+ const struct timespec *cur_to_ptr = NULL;
+ if (win_has_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= win_deadline_ns) {
+ if (win_is_positive_timeout) {
+ timed_out = true;
+ break;
+ }
+ cur_to.tv_sec = 0;
+ cur_to.tv_nsec = 0;
+ } else {
+ php_io_terminal_ns_to_timespec(win_deadline_ns - now, &cur_to);
+ }
+ cur_to_ptr = &cur_to;
+ }
+ int read_result = php_io_terminal_read_byte(
+ stream.native_stream, stream.php_stream, NULL, &byte, cur_to_ptr, false, NULL
+ );
+ if (read_result == PHP_IO_TERMINAL_READ_TIMEOUT) {
+ timed_out = true;
+ break;
+ }
+ if (read_result != PHP_IO_TERMINAL_READ_SUCCESS) {
+ timed_out = false;
+ break;
+ }
+
+win_process_key:
+ if (intern->last_secret_ended_in_cr) {
+ intern->last_secret_ended_in_cr = false;
+ if (byte == '\n') {
+ continue;
+ }
+ }
+
+ if (byte == '\r' || byte == '\n') {
+ if (byte == '\r') {
+ if (stream.php_stream != NULL && stream.php_stream->writepos > stream.php_stream->readpos && stream.php_stream->readbuf != NULL) {
+ if (stream.php_stream->readbuf[stream.php_stream->readpos] == '\n') {
+ stream.php_stream->readpos++;
+ }
+ } else {
+ intern->last_secret_ended_in_cr = true;
+ }
+ }
+ success = true;
+ break;
+ }
+ if (byte == 0x7f || byte == 0x08) {
+ php_io_terminal_buffer_remove_last_utf8_char(&sec_str);
+ continue;
+ }
+ if (byte == 0x03 || byte == 0x04) {
+ timed_out = false;
+ break;
+ }
+ if (byte == 0x1b) {
+ struct timespec esc_to;
+ if (win_has_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= win_deadline_ns) {
+ esc_to.tv_sec = 0;
+ esc_to.tv_nsec = 0;
+ } else {
+ zend_hrtime_t rem_ns = win_deadline_ns - now;
+ zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL;
+ php_io_terminal_ns_to_timespec(step_ns, &esc_to);
+ }
+ } else {
+ esc_to.tv_sec = 0;
+ esc_to.tv_nsec = 25000000L;
+ }
+
+ unsigned char next_b;
+ int rnext = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &next_b, &esc_to, false, NULL);
+ if (rnext != PHP_IO_TERMINAL_READ_SUCCESS) {
+ /* Standalone Escape cancels */
+ timed_out = false;
+ goto win_secret_done;
+ }
+ if (next_b == 0x03 || next_b == 0x04 || next_b == 0x1b || next_b == '\r' || next_b == '\n') {
+ timed_out = false;
+ goto win_secret_done;
+ }
+ if (next_b == '[') {
+ while (true) {
+ if (win_has_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= win_deadline_ns) {
+ if (win_is_positive_timeout) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ esc_to.tv_sec = 0;
+ esc_to.tv_nsec = 0;
+ } else {
+ zend_hrtime_t rem_ns = win_deadline_ns - now;
+ zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL;
+ php_io_terminal_ns_to_timespec(step_ns, &esc_to);
+ }
+ } else {
+ esc_to.tv_sec = 0;
+ esc_to.tv_nsec = 25000000L;
+ }
+
+ unsigned char csi_ch;
+ int csi_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &csi_ch, &esc_to, false, NULL);
+ if (csi_r != PHP_IO_TERMINAL_READ_SUCCESS) {
+ if (win_has_deadline && zend_hrtime() >= win_deadline_ns) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ break;
+ }
+ if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ if (csi_ch == 0x03 || csi_ch == 0x04 || csi_ch == 0x1b || csi_ch == '\r' || csi_ch == '\n') {
+ timed_out = false;
+ goto win_secret_done;
+ }
+ if (csi_ch >= 0x40 && csi_ch <= 0x7e) break;
+ }
+ continue;
+ }
+ if (next_b == 'O') {
+ if (win_has_deadline) {
+ zend_hrtime_t now = zend_hrtime();
+ if (now >= win_deadline_ns) {
+ if (win_is_positive_timeout) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ esc_to.tv_sec = 0;
+ esc_to.tv_nsec = 0;
+ } else {
+ zend_hrtime_t rem_ns = win_deadline_ns - now;
+ zend_hrtime_t step_ns = rem_ns < 25000000ULL ? rem_ns : 25000000ULL;
+ php_io_terminal_ns_to_timespec(step_ns, &esc_to);
+ }
+ } else {
+ esc_to.tv_sec = 0;
+ esc_to.tv_nsec = 25000000L;
+ }
+
+ unsigned char ss3_ch;
+ int ss3_r = php_io_terminal_read_byte(stream.native_stream, stream.php_stream, NULL, &ss3_ch, &esc_to, false, NULL);
+ if (ss3_r != PHP_IO_TERMINAL_READ_SUCCESS) {
+ if (win_has_deadline && zend_hrtime() >= win_deadline_ns) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ continue;
+ }
+ if (win_has_deadline && win_is_positive_timeout && zend_hrtime() >= win_deadline_ns) {
+ timed_out = true;
+ goto win_secret_done;
+ }
+ if (ss3_ch == 0x03 || ss3_ch == 0x04 || ss3_ch == 0x1b || ss3_ch == '\r' || ss3_ch == '\n') {
+ timed_out = false;
+ goto win_secret_done;
+ }
+ continue;
+ }
+ continue;
+ }
+ smart_str_appendc(&sec_str, (char) byte);
+ }
+
+win_secret_done:
+ if (success) {
+ secret = smart_str_extract(&sec_str);
+ } else {
+ smart_str_free(&sec_str);
+ }
+ }
+#else
+ struct timespec timeout;
+ const struct timespec *timeout_ptr = NULL;
+ 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, intern, &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_mode_token_ce->create_object = php_io_terminal_mode_token_create_object;
+ memcpy(&php_io_terminal_mode_token_handlers, zend_get_std_object_handlers(), sizeof(php_io_terminal_mode_token_handlers));
+ php_io_terminal_mode_token_handlers.offset = offsetof(php_io_terminal_mode_token_object, std);
+ php_io_terminal_mode_token_handlers.free_obj = php_io_terminal_mode_token_free_obj;
+ php_io_terminal_mode_token_handlers.clone_obj = NULL;
+
+ php_io_terminal_terminal_ce = register_class_Io_Terminal_Terminal();
+ php_io_terminal_terminal_ce->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_remove_active_shared_mode(shared);
+ if (shared->owner_pid == getpid() && !shared->is_non_tty && !shared->is_restored) {
+ php_io_terminal_restore_shared_mode(shared);
+ shared->is_restored = true;
+ }
+ php_io_terminal_shared_mode_delref(shared);
+ }
+ return SUCCESS;
+}
diff --git a/ext/standard/io_terminal.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..ca405fde4140
--- /dev/null
+++ b/ext/standard/io_terminal.stub.php
@@ -0,0 +1,101 @@
+
+--FILE--
+readKey(Duration::fromSeconds(0));
+var_dump($k1 === null);
+
+// Terminal 2 now attempts to read the stream without $term1's decoder state
+fwrite($fp, "\x82\xAC");
+fseek($fp, 1);
+
+// $term2 sees continuation bytes without the initial prefix
+$k2 = $term2->readKey(Duration::fromSeconds(0));
+// $term2 cannot assemble the β¬ symbol because it does not share $term1's pending buffer
+var_dump($k2 !== "β¬");
+
+// $term1, given its continuation, assembles the full sequence
+fseek($fp, 1);
+$k1_res = $term1->readKey(Duration::fromSeconds(0));
+var_dump($k1_res === "β¬");
+
+fclose($fp);
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_controlling_tty.phpt b/ext/standard/tests/terminal/terminal_controlling_tty.phpt
new file mode 100644
index 000000000000..cb0445b1f5df
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_controlling_tty.phpt
@@ -0,0 +1,58 @@
+--TEST--
+Io\Terminal\Terminal: /dev/tty controlling terminal identity
+--SKIPIF--
+
+--FILE--
+enableRawMode();
+var_dump($mTty instanceof ModeToken);
+
+// Descriptors to the same controlling terminal share identity
+$tTty2->restoreMode($mTty);
+echo "restored via tTty2
+";
+
+// Mode was already restored; reusing the token throws ValueError
+try {
+ $tTty1->restoreMode($mTty);
+ echo "FAIL: consumed token was accepted
+";
+} catch (ValueError $e) {
+ echo "Caught: ", $e->getMessage(), "
+";
+}
+
+fclose($ttyFp2);
+fclose($ttyFp1);
+?>
+--EXPECT--
+bool(true)
+restored via tTty2
+Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
diff --git a/ext/standard/tests/terminal/terminal_create.phpt b/ext/standard/tests/terminal/terminal_create.phpt
new file mode 100644
index 000000000000..9ddf03b35ad9
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_create.phpt
@@ -0,0 +1,113 @@
+--TEST--
+Io\Terminal\Terminal: fromStdio, fromStreams, construction and serialization restrictions
+--FILE--
+getMessage(), PHP_EOL;
+}
+
+try {
+ Terminal::fromStreams($fp, 123);
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// Constructor privacy for Terminal and ModeToken
+foreach ([Terminal::class, ModeToken::class] as $class) {
+ 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 Terminal
+try {
+ serialize($t1);
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+object(Io\Terminal\Terminal)#1 (0) {
+}
+object(Io\Terminal\Terminal)#2 (0) {
+}
+object(Io\Terminal\Terminal)#3 (0) {
+}
+TypeError: Io\Terminal\Terminal::fromStreams(): Argument #1 ($input) must be of type resource, string given
+TypeError: Io\Terminal\Terminal::fromStreams(): Argument #2 ($output) must be of type resource or null, int given
+Error: Call to private Io\Terminal\Terminal::__construct() from global scope
+Error: Call to private Io\Terminal\ModeToken::__construct() from global scope
+int(80)
+int(24)
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #1 ($cols) must be greater than 0
+ValueError: Io\Terminal\TerminalSize::__construct(): Argument #2 ($rows) must be greater than 0
+Error: Cannot modify readonly property Io\Terminal\TerminalSize::$cols
+bool(true)
+int(80)
+int(24)
+Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal
+Error: Trying to clone an uncloneable object of class Io\Terminal\Terminal
+Exception: Serialization of 'Io\Terminal\Terminal' is not allowed
diff --git a/ext/standard/tests/terminal/terminal_identity.phpt b/ext/standard/tests/terminal/terminal_identity.phpt
new file mode 100644
index 000000000000..205b3fea6161
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_identity.phpt
@@ -0,0 +1,137 @@
+--TEST--
+Io\Terminal\Terminal: POSIX terminal identity and unrelated PTY rejection
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes1,
+);
+
+$proc2 = proc_open(
+ [PHP_BINARY, '-r', 'fgets(STDIN);'],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+
+$t1 = Terminal::fromStreams($pipes1[0]);
+$t1_dup = Terminal::fromStreams($pipes1[1]);
+$t2 = Terminal::fromStreams($pipes2[0]);
+
+$m1_dup = $t1->enableRawMode();
+var_dump($m1_dup instanceof ModeToken);
+$t1_dup->restoreMode($m1_dup);
+echo "restored via t1_dup
+";
+
+$m1 = $t1->enableRawMode();
+try {
+ $t2->restoreMode($m1);
+ echo "FAIL: unrelated PTY accepted token
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+$t1->restoreMode($m1);
+echo "restored via t1
+";
+
+try {
+ $t1->restoreMode($m1);
+ echo "FAIL: consumed token accepted
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// Descriptor reuse: hold an active token, close the PTY, and open a new PTY that reuses the fd number
+$m_reuse = $t1->enableRawMode();
+fwrite($pipes1[0], "exit
+");
+unset($t1, $t1_dup);
+foreach ($pipes1 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc1);
+
+$proc3 = proc_open(
+ [PHP_BINARY, '-r', 'fgets(STDIN);'],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes3,
+);
+$t3 = Terminal::fromStreams($pipes3[0]);
+try {
+ $t3->restoreMode($m_reuse);
+ echo "FAIL: reused fd accepted token
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+fwrite($pipes2[0], "exit
+");
+fwrite($pipes3[0], "exit
+");
+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)
+restored via t1_dup
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+restored via t1
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
diff --git a/ext/standard/tests/terminal/terminal_interface_mock.phpt b/ext/standard/tests/terminal/terminal_interface_mock.phpt
new file mode 100644
index 000000000000..9097dddbbdc8
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_interface_mock.phpt
@@ -0,0 +1,71 @@
+--TEST--
+Io\Terminal\Terminal: final class boundary, dependency injection, and testing with streams
+--FILE--
+isFinal());
+var_dump($rcTerm->isInterface());
+
+$rcToken = new ReflectionClass(ModeToken::class);
+var_dump($rcToken->isFinal());
+var_dump($rcToken->isInterface());
+
+// Consumer accepts Terminal directly via dependency injection
+function readUserCommand(Terminal $term): string
+{
+ $token = $term->enableRawMode();
+ try {
+ $key = $term->readKey(Duration::fromSeconds(1));
+ if ($key instanceof Key) {
+ return "key:" . $key->name;
+ }
+ return "char:" . ($key ?? "none");
+ } finally {
+ $term->restoreMode($token);
+ }
+}
+
+// Testing with php://memory stream in pure PHP (Larry & Tim testing approach)
+$in = fopen('php://memory', 'w+');
+fwrite($in, "[A"); // Key::Up
+rewind($in);
+
+$term = Terminal::fromStreams($in);
+echo readUserCommand($term), PHP_EOL;
+
+// Non-tty raw mode token is valid ModeToken and restores cleanly
+$memStream = fopen('php://memory', 'w+');
+$memTerm = Terminal::fromStreams($memStream);
+$token = $memTerm->enableRawMode();
+var_dump($token instanceof ModeToken);
+$memTerm->restoreMode($token);
+echo "restored successfully
+";
+
+// Reusing consumed token throws ValueError
+try {
+ $memTerm->restoreMode($token);
+ echo "FAIL: accepted consumed token
+";
+} catch (ValueError $e) {
+ echo "Caught: ", $e->getMessage(), PHP_EOL;
+}
+
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+key:Up
+bool(true)
+restored successfully
+Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
diff --git a/ext/standard/tests/terminal/terminal_key_line_handoff.phpt b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt
new file mode 100644
index 000000000000..57f5be3142b7
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_key_line_handoff.phpt
@@ -0,0 +1,47 @@
+--TEST--
+Io\Terminal\Terminal: pending UTF-8 handoff from readKey to readLine
+--SKIPIF--
+
+--FILE--
+readKey(Duration::fromSeconds(0));
+ fwrite($fp, $continuation);
+ fseek($fp, 1);
+ return [$term, $fp];
+}
+
+// 1. Complete UTF-8 sequence: \xE2 + \x82\xACline\n => β¬line
+[$t1, $fp1] = stageLineHandoff("\x82\xACline\n");
+$line1 = $t1->readLine();
+var_dump($line1 === "β¬line");
+fclose($fp1);
+
+// 2. Incomplete UTF-8 at EOF: \xE2 at EOF returns pending byte
+[$t2, $fp2] = stageLineHandoff("");
+$line2 = $t2->readLine();
+var_dump($line2 === "\xE2");
+fclose($fp2);
+
+// 3. Malformed UTF-8: \xE2 followed by non-continuation ASCII
+[$t3, $fp3] = stageLineHandoff("Xline\n");
+$line3 = $t3->readLine();
+var_dump($line3 === "\xE2Xline");
+fclose($fp3);
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt
new file mode 100644
index 000000000000..4703430b38a3
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_key_secret_handoff.phpt
@@ -0,0 +1,86 @@
+--TEST--
+Io\Terminal\Terminal: pending UTF-8 handoff from readKey to readSecret
+--SKIPIF--
+
+--FILE--
+readKey(Duration::fromSeconds(0));
+ // Verify \xE2 timed out and is retained
+ if ($k !== null) {
+ echo "Unexpected key: " . bin2hex($k) . "\n";
+ }
+ fwrite($fp, $continuation);
+ fseek($fp, 1);
+ return $term;
+}
+
+// 1. Valid continuation: \xE2 + \x82\xAC + pass\n => β¬pass
+$t1 = stageHandoff("\x82\xACpass\n");
+$s1 = $t1->readSecret();
+var_dump($s1 === "β¬pass");
+
+// 2. Non-continuation Ctrl+C: must trigger cancellation, NOT swallow \x03 into secret
+$t2 = stageHandoff("\x03\n");
+try {
+ $s2 = $t2->readSecret();
+ echo "FAIL: Ctrl+C swallowed into secret: " . bin2hex($s2) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Ctrl+C cancelled after incomplete UTF-8\n";
+}
+
+// 3. Non-continuation Ctrl+D: must trigger cancellation, NOT swallow \x04 into secret
+$t3 = stageHandoff("\x04\n");
+try {
+ $s3 = $t3->readSecret();
+ echo "FAIL: Ctrl+D swallowed into secret: " . bin2hex($s3) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Ctrl+D cancelled after incomplete UTF-8\n";
+}
+
+// 4. Non-continuation Escape: must trigger cancellation, NOT swallow \x1B into secret
+$t4 = stageHandoff("\x1b\n");
+try {
+ $s4 = $t4->readSecret();
+ echo "FAIL: Escape swallowed into secret: " . bin2hex($s4) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Escape cancelled after incomplete UTF-8\n";
+}
+
+// 5. Non-continuation Enter: must terminate secret, NOT swallow newline into secret text
+$t5 = stageHandoff("\nsecond\n");
+try {
+ $s5 = $t5->readSecret();
+ // The secret must be terminated by the first newline
+ var_dump($s5 !== null && strpos($s5, "\n") === false);
+} catch (TerminalException $e) {
+ // Or throw if invalid UTF-8 at terminator
+ echo "PASS: Enter terminated/threw on incomplete UTF-8\n";
+}
+
+// 6. Non-continuation Backspace: must not appear in secret content
+$t6 = stageHandoff("\x08pass\n");
+$s6 = $t6->readSecret();
+var_dump(strpos($s6, "\x08") === false);
+
+?>
+--EXPECTF--
+bool(true)
+PASS: Ctrl+C cancelled after incomplete UTF-8
+PASS: Ctrl+D cancelled after incomplete UTF-8
+PASS: Escape cancelled after incomplete UTF-8
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt
new file mode 100644
index 000000000000..d8d6fe43986e
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_overlapping_raw_mode.phpt
@@ -0,0 +1,197 @@
+--TEST--
+Io\Terminal\Terminal: 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,
+);
+
+// Helper to check actual OS termios raw mode (ICANON/ECHO disabled)
+function isPtyRaw($stream): bool {
+ $sub = proc_open(['stty', '-a'], [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($sub)) return false;
+ $out = stream_get_contents($pipes[1]);
+ fclose($pipes[1]); fclose($pipes[2]); proc_close($sub);
+ return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false;
+}
+
+// Two separate Terminal instances referencing descriptors on the same PTY
+$t1 = Terminal::fromStreams($pipes[0]);
+$t2 = Terminal::fromStreams($pipes[1]);
+$tUnrelated = Terminal::fromStreams($pipes2[0]);
+
+// Test 1: Same Terminal + token
+$m = $t1->enableRawMode();
+var_dump($m instanceof ModeToken);
+var_dump(isPtyRaw($pipes[0]));
+$t1->restoreMode($m);
+var_dump(!isPtyRaw($pipes[0]));
+echo "restored m\n";
+
+// Test 2: Different Terminal on same device
+$m1 = $t1->enableRawMode();
+$t2->restoreMode($m1);
+echo "restored m1 via t2
+";
+
+// Test 3: Unrelated terminal rejection (ValueError)
+$m1 = $t1->enableRawMode();
+try {
+ $tUnrelated->restoreMode($m1);
+ echo "FAIL: unrelated terminal accepted token
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+$t1->restoreMode($m1);
+echo "restored m1 via t1
+";
+
+// Test 4: Token survives Terminal destruction when held externally
+$tTemp = Terminal::fromStreams($pipes[0]);
+$mTemp = $tTemp->enableRawMode();
+unset($tTemp); // Drops Terminal reference, but $mTemp remains active
+
+// $mTemp is still active and can be restored through another Terminal on the same device
+$t1->restoreMode($mTemp);
+echo "restored mTemp via t1
+";
+
+// After restoration, the token is consumed; reusing it throws ValueError
+try {
+ $t1->restoreMode($mTemp);
+ echo "FAIL: stale token after restoration accepted
+";
+} 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);
+var_dump(isPtyRaw($pipes[0]));
+
+// Out-of-order: restore older token m1 first
+$t1->restoreMode($m1);
+// Intermediate lease release: OS mode must remain raw
+var_dump(isPtyRaw($pipes[0]));
+echo "restored m1 out-of-order\n";
+
+// Now restore m2 (final lease)
+$t2->restoreMode($m2);
+// Final lease release: OS mode must be restored to canonical
+var_dump(!isPtyRaw($pipes[0]));
+echo "restored m2 out-of-order\n";
+
+// Test 6: In-order restore (m2 then m1)
+$m1 = $t1->enableRawMode();
+$m2 = $t2->enableRawMode();
+
+$t2->restoreMode($m2);
+echo "restored m2 in-order\n";
+$t1->restoreMode($m1);
+echo "restored m1 in-order\n";
+
+// Test 7: Destroying the final externally-held token restores canonical mode
+$tLive = Terminal::fromStreams($pipes[0]);
+$mHeld = $tLive->enableRawMode();
+var_dump(isPtyRaw($pipes[0]));
+unset($tLive); // Terminal dropped; $mHeld keeps raw mode active
+var_dump(isPtyRaw($pipes[0]));
+unset($mHeld); // Final token destroyed; ModeToken destructor restores canonical mode
+var_dump(!isPtyRaw($pipes[0]));
+
+// Reusing consumed m1 throws ValueError
+try {
+ $t1->restoreMode($m1);
+} catch (ValueError $e) {
+ echo "Reusing consumed token caught: ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes[0], "exit\n");
+fwrite($pipes2[0], "exit\n");
+unset($t1, $t2, $tUnrelated);
+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)
+restored m
+restored m1 via t2
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+restored m1 via t1
+restored mTemp via t1
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+restored m1 out-of-order
+bool(true)
+restored m2 out-of-order
+restored m2 in-order
+restored m1 in-order
+bool(true)
+bool(true)
+bool(true)
+Reusing consumed token caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
diff --git a/ext/standard/tests/terminal/terminal_raw_mode.phpt b/ext/standard/tests/terminal/terminal_raw_mode.phpt
new file mode 100644
index 000000000000..dbfbc85befc7
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode.phpt
@@ -0,0 +1,144 @@
+--TEST--
+Io\Terminal\Terminal: enableRawMode, restoreMode, session mode tracking and stale token protection
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+);
+
+$terminal = Terminal::fromStreams($pipes[0]);
+
+// Explicit token restore on valid terminal
+$token = $terminal->enableRawMode();
+echo "enableRawMode: ", ($token instanceof ModeToken ? "ModeToken" : "false"), "
+";
+
+// Restore explicitly with token
+$terminal->restoreMode($token);
+echo "restored token
+";
+
+// Stale consumed token must throw ValueError
+try {
+ $terminal->restoreMode($token);
+ echo "FAIL: stale token was accepted
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// Token survives Terminal object destruction when held externally
+$terminal3 = Terminal::fromStreams($pipes[0]);
+$token3 = $terminal3->enableRawMode();
+var_dump($token3 instanceof ModeToken);
+unset($terminal3); // Drops Terminal reference, but $token3 is held externally
+
+// Restoring the externally held token through another Terminal on the same terminal succeeds
+$terminal->restoreMode($token3);
+echo "restored token3 via terminal
+";
+
+// Once restored, reusing the token throws ValueError
+try {
+ $terminal->restoreMode($token3);
+ echo "FAIL: consumed token was accepted
+";
+} 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 = Terminal::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
+$terminalClosed->restoreMode($tokenClosed);
+echo "restored tokenClosed via owned descriptor
+";
+
+// Token is now consumed; reusing it throws ValueError
+try {
+ $terminalClosed->restoreMode($tokenClosed);
+ echo "FAIL: consumed token was accepted
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+proc_terminate($proc2);
+foreach ($pipes2 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc2);
+
+fwrite($pipes[0], "exit
+");
+unset($terminal, $terminalClosed);
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--EXPECT--
+enableRawMode: ModeToken
+restored token
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+bool(true)
+restored token3 via terminal
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+bool(true)
+restored tokenClosed via owned descriptor
+ValueError: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
diff --git a/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt b/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt
new file mode 100644
index 000000000000..5f1d8ffd52a7
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode_failure_injection.phpt
@@ -0,0 +1,126 @@
+--TEST--
+Io\Terminal\Terminal: raw mode acquisition and restoration failure rollback and retry
+--SKIPIF--
+ ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes
+ );
+} catch (Throwable) {
+ die("skip PTY not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY not available");
+}
+foreach ($pipes as $p) { if (is_resource($p)) fclose($p); }
+proc_close($proc);
+?>
+--FILE--
+ $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($sub)) return false;
+ $out = stream_get_contents($pipes[1]);
+ fclose($pipes[1]); fclose($pipes[2]); proc_close($sub);
+ return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false;
+}
+
+$proc = proc_open(
+ [PHP_BINARY, '-r', 'fgets(STDIN);'],
+ [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes
+);
+$term = Terminal::fromStreams($pipes[0]);
+
+// Test 1: Reapplication failure must not leak lease count
+$m1 = $term->enableRawMode();
+var_dump(isPtyRaw($pipes[0]));
+
+putenv("PHP_TERMINAL_TEST_SEAM=fail_reapply");
+try {
+ $m2 = $term->enableRawMode();
+ echo "FAIL: reapplication succeeded despite failure seam\n";
+} catch (Throwable $e) {
+ echo "PASS: reapplication rejected: " . $e->getMessage() . "\n";
+}
+putenv("PHP_TERMINAL_TEST_SEAM");
+
+// Because reapplication failed, lease count was NOT incremented.
+// Restoring $m1 must therefore restore canonical mode immediately!
+$term->restoreMode($m1);
+var_dump(!isPtyRaw($pipes[0]));
+
+// Test 2: Failed explicit restoration leaves retryable token
+$mRetry = $term->enableRawMode();
+var_dump(isPtyRaw($pipes[0]));
+
+putenv("PHP_TERMINAL_TEST_SEAM=fail_restore_once");
+try {
+ $term->restoreMode($mRetry);
+ echo "FAIL: restore succeeded despite failure seam\n";
+} catch (TerminalException $e) {
+ echo "PASS: restore threw: " . $e->getMessage() . "\n";
+}
+putenv("PHP_TERMINAL_TEST_SEAM");
+
+// Token must still be active and terminal still in raw mode
+var_dump(isPtyRaw($pipes[0]));
+
+// Retry restoration succeeds and restores mode
+$term->restoreMode($mRetry);
+var_dump(!isPtyRaw($pipes[0]));
+
+// Third restore attempt fails because token was consumed
+try {
+ $term->restoreMode($mRetry);
+ echo "FAIL: consumed token accepted on retry\n";
+} catch (ValueError $e) {
+ echo "PASS: token consumed on successful retry\n";
+}
+
+// Test 3: Postcondition failure rolls back initial mode
+putenv("PHP_TERMINAL_TEST_SEAM=fail_postcondition");
+try {
+ $mFail = $term->enableRawMode();
+ echo "FAIL: acquisition succeeded despite postcondition failure\n";
+} catch (Throwable $e) {
+ echo "PASS: postcondition failure caught: " . $e->getMessage() . "\n";
+}
+putenv("PHP_TERMINAL_TEST_SEAM");
+// Terminal must NOT be left in raw mode after failed acquisition
+var_dump(!isPtyRaw($pipes[0]));
+
+fwrite($pipes[0], "\n");
+fclose($pipes[0]);
+if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]);
+if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]);
+proc_terminate($proc);
+proc_close($proc);
+
+?>
+--EXPECTF--
+bool(true)
+PASS: reapplication rejected: %s
+bool(true)
+bool(true)
+PASS: restore threw: Failed to restore terminal mode
+bool(true)
+bool(true)
+PASS: token consumed on successful retry
+PASS: postcondition failure caught: %s
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt b/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt
new file mode 100644
index 000000000000..6fcddde61e88
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode_fork.phpt
@@ -0,0 +1,114 @@
+--TEST--
+Io\Terminal\Terminal: raw mode token ownership and child process fork isolation
+--SKIPIF--
+ ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes
+ );
+} catch (Throwable) {
+ die("skip PTY not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY not available");
+}
+foreach ($pipes as $p) { if (is_resource($p)) fclose($p); }
+proc_close($proc);
+?>
+--FILE--
+ ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes
+);
+
+$term = Terminal::fromStreams($pipes[0]);
+
+function isPtyRaw($stream): bool {
+ $sub = proc_open(['stty', '-a'], [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($sub)) return false;
+ $out = stream_get_contents($pipes[1]);
+ fclose($pipes[1]); fclose($pipes[2]); proc_close($sub);
+ return strpos($out, "-icanon") !== false && strpos($out, "-echo") !== false;
+}
+
+// 1. Parent enables raw mode
+$token = $term->enableRawMode();
+var_dump($token !== null);
+
+// 2. Fork child
+$pid = pcntl_fork();
+if ($pid === -1) {
+ die("Failed to fork");
+}
+
+if ($pid === 0) {
+ // Child process:
+ // A. Explicit restore by child must be rejected
+ try {
+ $term->restoreMode($token);
+ echo "CHILD_RESTORE_FAIL: restore succeeded in child\n";
+ } catch (\Throwable $e) {
+ echo "CHILD_RESTORE_PASS: " . $e->getMessage() . "\n";
+ }
+
+ // B. Child unsets inherited token and terminates
+ unset($token);
+ exit(0);
+}
+
+// Parent process waits for child to exit
+pcntl_waitpid($pid, $status);
+
+// Verify actual OS mode after child exit and before parent restoration
+var_dump(isPtyRaw($pipes[0]));
+
+// 3. Parent verifies its token is still active and can be restored
+try {
+ $term->restoreMode($token);
+ echo "PARENT_RESTORE_PASS\n";
+} catch (\Throwable $e) {
+ echo "PARENT_RESTORE_FAIL: " . $e->getMessage() . "\n";
+}
+
+// Verify actual OS mode after parent restoration
+var_dump(!isPtyRaw($pipes[0]));
+
+// 4. Token cannot be used a second time
+try {
+ $term->restoreMode($token);
+ echo "REUSE_FAIL\n";
+} catch (\Throwable $e) {
+ echo "REUSE_PASS\n";
+}
+
+fwrite($pipes[0], "\n");
+fclose($pipes[0]);
+if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]);
+if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]);
+proc_terminate($proc);
+proc_close($proc);
+
+?>
+--EXPECTF--
+bool(true)
+CHILD_RESTORE_PASS: %smust be an active terminal mode token belonging to this terminal
+bool(true)
+PARENT_RESTORE_PASS
+bool(true)
+REUSE_PASS
diff --git a/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt
new file mode 100644
index 000000000000..2dd11d857364
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode_isig_checks.phpt
@@ -0,0 +1,264 @@
+--TEST--
+Io\Terminal\Terminal: raw mode configuration when ECHO and ICANON are disabled but ISIG is enabled
+--SKIPIF--
+ ['pty'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $pipes
+ );
+} catch (Throwable) {
+ die("skip PTY not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY not available");
+}
+foreach ($pipes as $p) { if (is_resource($p)) fclose($p); }
+proc_close($proc);
+?>
+--FILE--
+ 0) {
+ $line = fgets($stream);
+ return ($line !== false) ? $line : null;
+ }
+ break;
+ }
+ return null;
+}
+
+function runIsolatedChild(string $code, ?string $inputToSend = null): string {
+ $descriptors = [
+ 0 => ['pty'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $proc = proc_open([PHP_BINARY, '-r', $code], $descriptors, $pipes);
+ if (!is_resource($proc)) {
+ return "FAIL: proc_open failed";
+ }
+
+ $cleaned = false;
+ $cleanup = function () use ($proc, &$pipes, &$cleaned): void {
+ if ($cleaned) {
+ return;
+ }
+ $cleaned = true;
+ foreach ($pipes as $p) {
+ if (is_resource($p)) {
+ fclose($p);
+ }
+ }
+ if (is_resource($proc)) {
+ $status = proc_get_status($proc);
+ if ($status['running']) {
+ $exitDeadline = getMonotonicTime() + 0.2;
+ while (proc_get_status($proc)['running'] && getMonotonicTime() < $exitDeadline) {
+ usleep(2000);
+ }
+ }
+ $status = proc_get_status($proc);
+ if ($status['running']) {
+ @proc_terminate($proc);
+ $termDeadline = getMonotonicTime() + 0.3;
+ while (proc_get_status($proc)['running'] && getMonotonicTime() < $termDeadline) {
+ usleep(2000);
+ }
+ if (proc_get_status($proc)['running'] && defined('SIGKILL')) {
+ @proc_terminate($proc, SIGKILL);
+ usleep(2000);
+ }
+ }
+ proc_close($proc);
+ }
+ };
+
+ // Readiness synchronization: wait for child to signal READY
+ $ready = readLineWithTimeout($pipes[1], 3.0);
+ if ($ready === null || trim($ready) !== 'READY') {
+ stream_set_blocking($pipes[2], false);
+ $stderr = stream_get_contents($pipes[2]);
+ $cleanup();
+ return "FAIL: child not ready: " . var_export($ready, true) . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : "");
+ }
+
+ if ($inputToSend !== null) {
+ $isigDisabled = false;
+ $obsDeadline = getMonotonicTime() + 1.5;
+ while (getMonotonicTime() < $obsDeadline) {
+ $sub = @proc_open(['stty', '-a'], [0 => $pipes[0], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $subPipes);
+ if (is_resource($sub)) {
+ $out = stream_get_contents($subPipes[1]);
+ fclose($subPipes[1]);
+ fclose($subPipes[2]);
+ $exitCode = proc_close($sub);
+ if ($exitCode === 0 && preg_match('/(?:\s|^)-isig(?:\s|$)/', $out) === 1) {
+ $isigDisabled = true;
+ break;
+ }
+ }
+ usleep(5000);
+ }
+
+ if (!$isigDisabled) {
+ stream_set_blocking($pipes[2], false);
+ $stderr = stream_get_contents($pipes[2]);
+ $cleanup();
+ return "FAIL: timed out waiting for child to disable ISIG" . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : "");
+ }
+
+ fwrite($pipes[0], $inputToSend);
+ fflush($pipes[0]);
+ }
+
+ // Read result with bounded timeout
+ $result = readLineWithTimeout($pipes[1], 3.0);
+
+ // Drain stderr non-blockingly
+ stream_set_blocking($pipes[2], false);
+ $stderr = stream_get_contents($pipes[2]);
+
+ $cleanup();
+
+ if ($result === null) {
+ return "FAIL: timed out or EOF reading child result" . (!empty($stderr) ? " (stderr: " . trim($stderr) . ")" : "");
+ }
+
+ if (!empty($stderr)) {
+ return "STDERR: " . trim($stderr);
+ }
+
+ return trim($result);
+}
+
+// Child helper functions template
+$setupHelper = '
+function setSttyMode(): void {
+ $sub = proc_open(["stty", "-echo", "-icanon", "isig"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp);
+ if (is_resource($sub)) {
+ fclose($sp[1]); fclose($sp[2]); proc_close($sub);
+ }
+}
+function isPrecedingModeRestored(): bool {
+ $sub = proc_open(["stty", "-a"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp);
+ if (!is_resource($sub)) return false;
+ $out = stream_get_contents($sp[1]);
+ fclose($sp[1]); fclose($sp[2]); proc_close($sub);
+ $hasIsig = preg_match("/(?:\s|^)isig(?:\s|$)/", $out) === 1 && preg_match("/(?:\s|^)-isig(?:\s|$)/", $out) !== 1;
+ $hasMinusEcho = strpos($out, "-echo") !== false;
+ $hasMinusIcanon = strpos($out, "-icanon") !== false;
+ return $hasIsig && $hasMinusEcho && $hasMinusIcanon;
+}
+';
+
+// Case 1: readKey() receives Ctrl+C as input byte and restores preceding mode
+$codeKey = $setupHelper . '
+use Io\Terminal\Terminal;
+use Time\Duration;
+if (function_exists("pcntl_alarm")) pcntl_alarm(5);
+setSttyMode();
+$t = Terminal::fromStdio();
+fwrite(STDOUT, "READY\n");
+fflush(STDOUT);
+$key = $t->readKey(Duration::fromSeconds(2));
+$restored = isPrecedingModeRestored();
+echo ($key === "\x03" && $restored) ? "PASS: readKey received Ctrl+C and restored mode" : "FAIL: key=" . bin2hex((string)$key) . " restored=" . ($restored ? "1" : "0");
+';
+
+echo runIsolatedChild($codeKey, "\x03"), "\n";
+
+// Case 2: readSecret() handles Ctrl+C through TerminalException and restores preceding mode
+$codeSecret = $setupHelper . '
+use Io\Terminal\Terminal;
+use Io\Terminal\TerminalException;
+use Time\Duration;
+if (function_exists("pcntl_alarm")) pcntl_alarm(5);
+setSttyMode();
+$t = Terminal::fromStdio();
+fwrite(STDOUT, "READY\n");
+fflush(STDOUT);
+$caught = false;
+try {
+ $t->readSecret(Duration::fromSeconds(2));
+} catch (TerminalException $e) {
+ $caught = true;
+}
+$restored = isPrecedingModeRestored();
+echo ($caught && $restored) ? "PASS: readSecret threw TerminalException on Ctrl+C and restored mode" : "FAIL: caught=" . ($caught ? "1" : "0") . " restored=" . ($restored ? "1" : "0");
+';
+
+echo runIsolatedChild($codeSecret, "\x03"), "\n";
+
+// Case 3: Shared-lease reapplication path applies raw mode when ISIG is enabled and restores afterward
+$codeLease = $setupHelper . '
+use Io\Terminal\Terminal;
+if (function_exists("pcntl_alarm")) pcntl_alarm(5);
+setSttyMode();
+$term = Terminal::fromStdio();
+fwrite(STDOUT, "READY\n");
+fflush(STDOUT);
+$t1 = $term->enableRawMode();
+
+// Alter terminal mode back to -echo -icanon isig
+setSttyMode();
+
+// Reapply lease while first lease is still active
+$t2 = $term->enableRawMode();
+
+// Verify ISIG is now disabled in terminal
+$sub = proc_open(["stty", "-a"], [0 => STDIN, 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $sp);
+$out = stream_get_contents($sp[1]);
+fclose($sp[1]); fclose($sp[2]); proc_close($sub);
+$hasMinusIsig = strpos($out, "-isig") !== false;
+
+$term->restoreMode($t2);
+$term->restoreMode($t1);
+
+$restored = isPrecedingModeRestored();
+echo ($hasMinusIsig && $restored) ? "PASS: lease reapplication disabled ISIG and restored mode" : "FAIL: raw=" . ($hasMinusIsig ? "1" : "0") . " restored=" . ($restored ? "1" : "0");
+';
+
+echo runIsolatedChild($codeLease, null), "\n";
+?>
+--EXPECT--
+PASS: readKey received Ctrl+C and restored mode
+PASS: readSecret threw TerminalException on Ctrl+C and restored mode
+PASS: lease reapplication disabled ISIG and restored mode
diff --git a/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt
new file mode 100644
index 000000000000..79b1bd0fcf97
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode_non_tty.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Io\Terminal\Terminal: no-op raw-mode leases on non-terminal streams
+--FILE--
+enableRawMode();
+var_dump($token instanceof ModeToken);
+
+// getSize() continues to return null on non-terminal streams
+var_dump($terminal->getSize());
+
+// Restoring the token succeeds
+$terminal->restoreMode($token);
+echo "restored non-tty token
+";
+
+// Re-restoring the consumed token throws ValueError
+try {
+ $terminal->restoreMode($token);
+ echo "FAIL: re-restoring consumed non-tty token accepted
+";
+} catch (ValueError $e) {
+ echo "Caught: ", $e->getMessage(), PHP_EOL;
+}
+
+fclose($fp);
+?>
+--EXPECT--
+bool(true)
+NULL
+restored non-tty token
+Caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
diff --git a/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt
new file mode 100644
index 000000000000..9a86dec8924a
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_raw_mode_output_flags.phpt
@@ -0,0 +1,117 @@
+--TEST--
+Io\Terminal\Terminal: c_oflag is preserved when entering raw mode with OPOST on and off
+--SKIPIF--
+ ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes
+ );
+} catch (Throwable) {
+ die("skip PTY not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY not available");
+}
+foreach ($pipes as $p) { if (is_resource($p)) fclose($p); }
+proc_close($proc);
+?>
+--FILE--
+ $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($sub)) {
+ return false;
+ }
+ $out = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($sub);
+ $hasOpost = preg_match('/(?:\s|^)opost(?:\s|$)/', $out) === 1;
+ $hasMinusOpost = preg_match('/(?:\s|^)-opost(?:\s|$)/', $out) === 1;
+ return $hasOpost && !$hasMinusOpost;
+}
+
+function setOpostState($stream, bool $enable): void {
+ $cmd = $enable ? ['stty', 'opost'] : ['stty', '-opost'];
+ $sub = proc_open($cmd, [0 => $stream, 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (is_resource($sub)) {
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($sub);
+ }
+}
+
+// Case 1: OPOST is initially ON
+$proc1 = proc_open(
+ [PHP_BINARY, '-r', 'fgets(STDIN);'],
+ [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes1
+);
+$term1 = Terminal::fromStreams($pipes1[0]);
+
+$before1 = getOpostState($pipes1[0]);
+$token1 = $term1->enableRawMode();
+$during1 = getOpostState($pipes1[0]);
+$term1->restoreMode($token1);
+$after1 = getOpostState($pipes1[0]);
+
+// OPOST was ON before, must stay ON during raw mode and after restore
+var_dump($before1 === true);
+var_dump($during1 === true);
+var_dump($after1 === true);
+
+fwrite($pipes1[0], "\n");
+fclose($pipes1[0]);
+if (isset($pipes1[1]) && is_resource($pipes1[1])) fclose($pipes1[1]);
+if (isset($pipes1[2]) && is_resource($pipes1[2])) fclose($pipes1[2]);
+proc_terminate($proc1);
+proc_close($proc1);
+
+// Case 2: OPOST is initially OFF
+$proc2 = proc_open(
+ [PHP_BINARY, '-r', 'fgets(STDIN);'],
+ [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
+ $pipes2
+);
+$term2 = Terminal::fromStreams($pipes2[0]);
+
+// Turn OPOST OFF explicitly before acquiring raw mode
+setOpostState($pipes2[0], false);
+
+$before2 = getOpostState($pipes2[0]);
+$token2 = $term2->enableRawMode();
+$during2 = getOpostState($pipes2[0]);
+$term2->restoreMode($token2);
+$after2 = getOpostState($pipes2[0]);
+
+// OPOST was OFF before, must NOT be forced ON during raw mode, and remain OFF after restore
+var_dump($before2 === false);
+var_dump($during2 === false);
+var_dump($after2 === false);
+
+fwrite($pipes2[0], "\n");
+fclose($pipes2[0]);
+if (isset($pipes2[1]) && is_resource($pipes2[1])) fclose($pipes2[1]);
+if (isset($pipes2[2]) && is_resource($pipes2[2])) fclose($pipes2[2]);
+proc_terminate($proc2);
+proc_close($proc2);
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_read_key.phpt b/ext/standard/tests/terminal/terminal_read_key.phpt
new file mode 100644
index 000000000000..abf3877c59e7
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key.phpt
@@ -0,0 +1,41 @@
+--TEST--
+Io\Terminal\Terminal: readKey with Time\Duration timeouts and parameter validation
+--FILE--
+readKey(Duration::fromSeconds(1)->negate());
+ echo "FAIL: accepted negative timeout
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+try {
+ $t->readKey(Duration::fromSeconds(0), Duration::fromSeconds(1)->negate());
+ echo "FAIL: accepted negative sequence timeout
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// Non-terminal stream reads keys without throwing
+$fp = fopen('php://temp', 'r+');
+fwrite($fp, "x");
+rewind($fp);
+$nonTty = Terminal::fromStreams($fp);
+$key = $nonTty->readKey(Duration::fromSeconds(0));
+var_dump($key === "x");
+fclose($fp);
+
+?>
+--EXPECT--
+ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative
+ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_read_key_contracts.phpt b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt
new file mode 100644
index 000000000000..1a532f6524b2
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_contracts.phpt
@@ -0,0 +1,123 @@
+--TEST--
+Io\Terminal\Terminal: 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 = Terminal::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
+";
+} 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
+";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+// Contract 4: Non-terminal input stream reads keys normally
+$fp = fopen('php://temp', 'r+');
+fwrite($fp, "z");
+rewind($fp);
+$nonTty = Terminal::fromStreams($fp);
+$keyNonTty = $nonTty->readKey(Duration::fromSeconds(0));
+var_dump($keyNonTty === "z");
+fclose($fp);
+
+// Contract 5: EOF / stream termination throws TerminalException on real TTY
+if (isset($pipes[1]) && is_resource($pipes[1])) fclose($pipes[1]);
+if (isset($pipes[2]) && is_resource($pipes[2])) fclose($pipes[2]);
+proc_terminate($proc);
+$deadline = hrtime(true) + 1_000_000_000;
+while (proc_get_status($proc)['running'] && hrtime(true) < $deadline) {
+ usleep(5000);
+}
+
+try {
+ $terminal->readKey(Duration::fromSeconds(1));
+ echo "FAIL: readKey on closed/EOF terminal did not throw\n";
+} catch (Throwable $e) {
+ echo $e::class, ": ", $e->getMessage(), PHP_EOL;
+}
+
+unset($terminal);
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+ValueError: Io\Terminal\Terminal::readKey(): Argument #1 ($timeout) must not be negative
+ValueError: Io\Terminal\Terminal::readKey(): Argument #2 ($sequenceTimeout) must not be negative
+bool(true)
+Io\Terminal\TerminalException: %s terminal input stream
diff --git a/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt b/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt
new file mode 100644
index 000000000000..708b478616c2
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_ordinary_stream_eof.phpt
@@ -0,0 +1,158 @@
+--TEST--
+Io\Terminal\Terminal: readKey EOF behavior on ordinary streams
+--SKIPIF--
+
+--FILE--
+readKey();
+ echo "FAIL: default timeout on empty stream did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: default timeout immediate EOF: " . $e->getMessage() . "\n";
+}
+fclose($fp);
+
+// Zero timeout
+$fp = fopen('php://temp', 'r+');
+$term = Terminal::fromStreams($fp);
+try {
+ $term->readKey(Duration::fromSeconds(0));
+ echo "FAIL: zero timeout on empty stream did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: zero timeout immediate EOF: " . $e->getMessage() . "\n";
+}
+fclose($fp);
+
+// Positive timeout
+$fp = fopen('php://temp', 'r+');
+$term = Terminal::fromStreams($fp);
+try {
+ $term->readKey(Duration::fromMilliseconds(50));
+ echo "FAIL: positive timeout on empty stream did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: positive timeout immediate EOF: " . $e->getMessage() . "\n";
+}
+fclose($fp);
+
+// 2. Reading an available character successfully, then encountering EOF:
+// Zero timeout on subsequent read
+$fp = fopen('php://temp', 'r+');
+fwrite($fp, "A");
+rewind($fp);
+$term = Terminal::fromStreams($fp);
+$key = $term->readKey(Duration::fromSeconds(0));
+var_dump($key === "A");
+try {
+ $term->readKey(Duration::fromSeconds(0));
+ echo "FAIL: subsequent zero timeout read did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: subsequent zero timeout read EOF: " . $e->getMessage() . "\n";
+}
+fclose($fp);
+
+// Positive timeout on subsequent read
+$fp = fopen('php://temp', 'r+');
+fwrite($fp, "B");
+rewind($fp);
+$term = Terminal::fromStreams($fp);
+$key = $term->readKey(Duration::fromMilliseconds(50));
+var_dump($key === "B");
+try {
+ $term->readKey(Duration::fromMilliseconds(50));
+ echo "FAIL: subsequent positive timeout read did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: subsequent positive timeout read EOF: " . $e->getMessage() . "\n";
+}
+fclose($fp);
+
+// Default timeout on subsequent read
+$fp = fopen('php://temp', 'r+');
+fwrite($fp, "C");
+rewind($fp);
+$term = Terminal::fromStreams($fp);
+$key = $term->readKey();
+var_dump($key === "C");
+try {
+ $term->readKey();
+ echo "FAIL: subsequent default timeout read did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: subsequent default timeout read EOF: " . $e->getMessage() . "\n";
+}
+fclose($fp);
+
+// 3. An open input with no data yet still returning null on timeout:
+$pair = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+if ($pair === false) {
+ die("Failed to create socket pair");
+}
+
+$readStream = $pair[0];
+$writeStream = $pair[1];
+$socketTerm = Terminal::fromStreams($readStream);
+
+// Zero timeout returns null immediately when open but no data
+$kZero = $socketTerm->readKey(Duration::fromSeconds(0));
+var_dump($kZero === null);
+
+// Positive timeout returns null when open but no data
+$kPos = $socketTerm->readKey(Duration::fromMilliseconds(30));
+var_dump($kPos === null);
+
+// Close writer to signal EOF on the open reader
+fclose($writeStream);
+
+// Now that writer is closed, reading encounters EOF and throws TerminalException
+try {
+ $socketTerm->readKey(Duration::fromSeconds(0));
+ echo "FAIL: socket zero timeout EOF did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: socket zero timeout EOF: " . $e->getMessage() . "\n";
+}
+
+try {
+ $socketTerm->readKey(Duration::fromMilliseconds(30));
+ echo "FAIL: socket positive timeout EOF did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: socket positive timeout EOF: " . $e->getMessage() . "\n";
+}
+
+try {
+ $socketTerm->readKey();
+ echo "FAIL: socket default timeout EOF did not throw\n";
+} catch (TerminalException $e) {
+ echo "PASS: socket default timeout EOF: " . $e->getMessage() . "\n";
+}
+
+fclose($readStream);
+?>
+--EXPECT--
+PASS: default timeout immediate EOF: End of file reached on terminal input stream
+PASS: zero timeout immediate EOF: End of file reached on terminal input stream
+PASS: positive timeout immediate EOF: End of file reached on terminal input stream
+bool(true)
+PASS: subsequent zero timeout read EOF: End of file reached on terminal input stream
+bool(true)
+PASS: subsequent positive timeout read EOF: End of file reached on terminal input stream
+bool(true)
+PASS: subsequent default timeout read EOF: End of file reached on terminal input stream
+bool(true)
+bool(true)
+PASS: socket zero timeout EOF: End of file reached on terminal input stream
+PASS: socket positive timeout EOF: End of file reached on terminal input stream
+PASS: socket default timeout EOF: End of file reached on terminal input stream
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..67f069947a4e
--- /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 = Terminal::fromStreams($pipes1[0]);
+$token1 = $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 = Terminal::fromStreams($pipes2[0]);
+$token2 = $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..1967a7893e73
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_sequences.phpt
@@ -0,0 +1,86 @@
+--TEST--
+Io\Terminal\Terminal: readKey preserves unsupported CSI sequences and decodes Insert
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+);
+
+$terminal = Terminal::fromStreams($pipes[0]);
+$token = $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
+Insert
+F1
+Up
diff --git a/ext/standard/tests/terminal/terminal_read_key_timeout.phpt b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt
new file mode 100644
index 000000000000..83fcbc99b7f0
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_timeout.phpt
@@ -0,0 +1,75 @@
+--TEST--
+Io\Terminal\Terminal: readKey zero timeout returns Escape immediately without sequence delay
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+);
+
+$terminal = Terminal::fromStreams($pipes[0]);
+$token = $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..94c6b36a162e
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_utf8_retain.phpt
@@ -0,0 +1,210 @@
+--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 = Terminal::fromStreams($pipes1[0]);
+$token1 = $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 = Terminal::fromStreams($pipes2[0]);
+$token2 = $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 = Terminal::fromStreams($pipes3[0]);
+$token3 = $terminal3->enableRawMode();
+
+fwrite($pipes3[0], "START\n");
+$r1 = fgets($pipes3[2]);
+
+$k5 = $terminal3->readKey(Duration::fromMilliseconds(30), Duration::fromMilliseconds(20));
+
+fwrite($pipes3[0], "GO\n");
+
+$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..e502868ede63
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_key_utf8_timing.phpt
@@ -0,0 +1,226 @@
+--TEST--
+Io\Terminal\Terminal: readKey UTF-8 timing and overall timeout behavior
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+ $started = fgets($pipes[2]);
+ $terminal = Terminal::fromStreams($pipes[0]);
+ $token = $terminal->enableRawMode();
+ return [$proc, $pipes, $terminal, $token];
+}
+
+// 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, $token1] = 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($token1);
+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, $token2] = 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($token2);
+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, $token3] = 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($token3);
+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, $token4] = 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($token4);
+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, $token5] = 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($token5);
+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..ee2d9fb2c51e
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_line.phpt
@@ -0,0 +1,189 @@
+--TEST--
+Io\Terminal\Terminal: readLine line ending, whitespace, buffering, and EOF stream contracts
+--FILE--
+isFinal());
+$reflection = new ReflectionMethod(Terminal::class, 'readLine');
+var_dump($reflection->getNumberOfParameters());
+var_dump((string) $reflection->getReturnType());
+
+// 1. Argument validation: readLine takes 0 parameters
+$fp = fopen('php://temp', 'r+');
+$terminal = Terminal::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);
+// Terminal::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 = Terminal::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 = Terminal::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 on non-terminal stream
+$token2 = $t2->enableRawMode();
+$t2->restoreMode($token2);
+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\Terminal::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
+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..c4167f8729ab
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_line_lease.phpt
@@ -0,0 +1,162 @@
+--TEST--
+Io\Terminal\Terminal: 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 = Terminal::fromStreams($pipes[1]);
+$t2 = Terminal::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
+$t2->restoreMode($token);
+echo "restored via t2\n";
+
+// 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. Restoring an already consumed token throws ValueError
+$tokenStale = $t1->enableRawMode();
+$t2->restoreMode($tokenStale);
+try {
+ $t1->restoreMode($tokenStale);
+ echo "FAIL: stale token was accepted\n";
+} catch (ValueError $e) {
+ echo "Stale token caught: ", $e->getMessage(), PHP_EOL;
+}
+
+// 7. Fragmented UTF-8 prepending: pending UTF-8 bytes from readKey are prepended to readLine
+$token2 = $t1->enableRawMode();
+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
+restored via t2
+string(14) "canonical line"
+string(11) "second line"
+Stale token caught: Io\Terminal\Terminal::restoreMode(): Argument #1 ($mode) must be an active terminal mode token belonging to this terminal
+bool(true)
+string(7) "Γ©clair"
+NULL
diff --git a/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt
new file mode 100644
index 000000000000..9b44c3101078
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_line_nonblocking.phpt
@@ -0,0 +1,159 @@
+--TEST--
+Io\Terminal\Terminal: 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 = Terminal::fromStreams($pipes1[1]);
+
+fwrite($pipes1[0], "GO\n");
+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 = Terminal::fromStreams($pipes2[1]);
+
+fwrite($pipes2[0], "GO\n");
+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 = Terminal::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 = Terminal::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..baf63b40ccec
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_line_windows.phpt
@@ -0,0 +1,62 @@
+--TEST--
+Io\Terminal\Terminal: 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. No-op raw mode lease on non-terminal stream
+$token = $terminal->enableRawMode();
+var_dump($token instanceof ModeToken);
+$terminal->restoreMode($token);
+echo "restored non-tty token\n";
+
+ftruncate($fp, 0);
+rewind($fp);
+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
+bool(true)
+restored non-tty token
+string(23) "usable after mode check"
diff --git a/ext/standard/tests/terminal/terminal_read_secret.phpt b/ext/standard/tests/terminal/terminal_read_secret.phpt
new file mode 100644
index 000000000000..14b28590608a
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_secret.phpt
@@ -0,0 +1,55 @@
+--TEST--
+Io\Terminal\Terminal: readSecret parameter validation and empty non-tty handling
+--FILE--
+readSecret();
+ echo "FAIL: readSecret succeeded on empty 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;
+}
+
+fclose($fp);
+
+?>
+--EXPECTF--
+bool(true)
+Io\Terminal\TerminalException: Unable to read secret from terminal
+TypeError: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must be of type ?Time\Duration, string given
+ArgumentCountError: Io\Terminal\Terminal::readSecret() expects at most 1 argument, 2 given
+ValueError: Io\Terminal\Terminal::readSecret(): Argument #1 ($timeout) must not be negative
diff --git a/ext/standard/tests/terminal/terminal_read_secret_control.phpt b/ext/standard/tests/terminal/terminal_read_secret_control.phpt
new file mode 100644
index 000000000000..26ad3f7ade50
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_secret_control.phpt
@@ -0,0 +1,358 @@
+--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 = Terminal::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. Escape followed by Ctrl+C (\x1b\x03) cancels secret input immediately
+$code2 = 'fwrite(STDOUT, "\x1b\x03\r"); fflush(STDOUT); fgets(STDIN);';
+$proc2 = proc_open(
+ [PHP_BINARY, '-r', $code2],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes2,
+);
+
+$terminal2 = Terminal::fromStreams($pipes2[0]);
+
+try {
+ $secret = $terminal2->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes2[0], "done\n");
+unset($terminal2);
+foreach ($pipes2 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc2);
+
+// 3. CSI sequence containing Ctrl+C aborts
+$code3 = 'fwrite(STDOUT, "\x1b[\x03A\r"); fflush(STDOUT); fgets(STDIN);';
+$proc3 = proc_open(
+ [PHP_BINARY, '-r', $code3],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes3,
+);
+
+$terminal3 = Terminal::fromStreams($pipes3[0]);
+
+try {
+ $secret = $terminal3->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes3[0], "done\n");
+unset($terminal3);
+foreach ($pipes3 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc3);
+
+// 4. SS3 sequence containing Ctrl+C aborts
+$code4 = 'fwrite(STDOUT, "\x1bO\x03\r"); fflush(STDOUT); fgets(STDIN);';
+$proc4 = proc_open(
+ [PHP_BINARY, '-r', $code4],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes4,
+);
+
+$terminal4 = Terminal::fromStreams($pipes4[0]);
+
+try {
+ $secret = $terminal4->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: 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 by enter (\x1b\r) aborts
+$code5 = 'fwrite(STDOUT, "\x1b\r"); fflush(STDOUT); fgets(STDIN);';
+$proc5 = proc_open(
+ [PHP_BINARY, '-r', $code5],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes5,
+);
+
+$terminal5 = Terminal::fromStreams($pipes5[0]);
+
+try {
+ $secret = $terminal5->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: 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. Escape followed by escape (\x1b\x1b) aborts
+$code6 = 'fwrite(STDOUT, "\x1b\x1b\r"); fflush(STDOUT); fgets(STDIN);';
+$proc6 = proc_open(
+ [PHP_BINARY, '-r', $code6],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes6,
+);
+
+$terminal6 = Terminal::fromStreams($pipes6[0]);
+
+try {
+ $secret = $terminal6->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: 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. Escape followed by Ctrl+D (\x1b\x04) aborts
+$code7 = 'fwrite(STDOUT, "\x1b\x04\r"); fflush(STDOUT); fgets(STDIN);';
+$proc7 = proc_open(
+ [PHP_BINARY, '-r', $code7],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes7,
+);
+
+$terminal7 = Terminal::fromStreams($pipes7[0]);
+
+try {
+ $secret = $terminal7->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: 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. CSI sequence with cancellation byte \x04 aborts
+$code8 = 'fwrite(STDOUT, "\x1b[\x04\r"); fflush(STDOUT); fgets(STDIN);';
+$proc8 = proc_open(
+ [PHP_BINARY, '-r', $code8],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes8,
+);
+
+$terminal8 = Terminal::fromStreams($pipes8[0]);
+
+try {
+ $secret = $terminal8->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: 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. CSI sequence with cancellation byte \x1b aborts
+$code9 = 'fwrite(STDOUT, "\x1b[\x1b\r"); fflush(STDOUT); fgets(STDIN);';
+$proc9 = proc_open(
+ [PHP_BINARY, '-r', $code9],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes9,
+);
+
+$terminal9 = Terminal::fromStreams($pipes9[0]);
+
+try {
+ $secret = $terminal9->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes9[0], "done\n");
+unset($terminal9);
+foreach ($pipes9 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc9);
+
+// 10. CSI sequence with cancellation byte \r aborts
+$code10 = 'fwrite(STDOUT, "\x1b[\r"); fflush(STDOUT); fgets(STDIN);';
+$proc10 = proc_open(
+ [PHP_BINARY, '-r', $code10],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes10,
+);
+
+$terminal10 = Terminal::fromStreams($pipes10[0]);
+
+try {
+ $secret = $terminal10->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes10[0], "done\n");
+unset($terminal10);
+foreach ($pipes10 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc10);
+
+// 11. SS3 sequence with cancellation byte \x04 aborts
+$code11 = 'fwrite(STDOUT, "\x1bO\x04\r"); fflush(STDOUT); fgets(STDIN);';
+$proc11 = proc_open(
+ [PHP_BINARY, '-r', $code11],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes11,
+);
+
+$terminal11 = Terminal::fromStreams($pipes11[0]);
+
+try {
+ $secret = $terminal11->readSecret();
+ echo "FAIL: secret was read: ", bin2hex($secret), "\n";
+} catch (TerminalException $e) {
+ echo "SUCCESS: caught ", $e::class, ": ", $e->getMessage(), "\n";
+}
+
+fwrite($pipes11[0], "done\n");
+unset($terminal11);
+foreach ($pipes11 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc11);
+
+?>
+--EXPECT--
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
+SUCCESS: caught Io\Terminal\TerminalException: Unable to read secret from terminal
diff --git a/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt
new file mode 100644
index 000000000000..b0fc296ecf04
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_read_secret_timeout.phpt
@@ -0,0 +1,298 @@
+--TEST--
+Io\Terminal\Terminal: readSecret timeout, empty submission, and cancellation contracts
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes1,
+);
+$terminal1 = Terminal::fromStreams($pipes1[0]);
+$start = hrtime(true);
+$secret1 = $terminal1->readSecret(Duration::fromMilliseconds(25));
+$elapsed_ms = (hrtime(true) - $start) / 1e6;
+
+var_dump($secret1 === null);
+var_dump($elapsed_ms >= 20.0);
+
+fwrite($pipes1[0], "done\n");
+unset($terminal1);
+foreach ($pipes1 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc1);
+
+// 2. Empty submission returning empty string ""
+$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 = Terminal::fromStreams($pipes2[0]);
+$token2 = $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 = Terminal::fromStreams($pipes3[0]);
+$token3 = $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 = Terminal::fromStreams($pipes4[0]);
+$token4 = $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 = Terminal::fromStreams($pipes5[0]);
+try {
+ $terminal5->readSecret(Duration::fromSeconds(1)->negate());
+ echo "FAIL: expected ValueError on negative duration\n";
+} catch (ValueError $e) {
+ echo "Negative timeout caught: ", $e->getMessage(), PHP_EOL;
+}
+
+fwrite($pipes5[0], "done\n");
+unset($terminal5);
+foreach ($pipes5 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc5);
+
+// 6. Complete secret plus newline already buffered: zero timeout consumes immediately available bytes without blocking
+$code6 = '
+fwrite(STDERR, "STARTED\n");
+fflush(STDERR);
+fgets(STDIN);
+
+fwrite(STDOUT, "zerosecret\n");
+fflush(STDOUT);
+fwrite(STDERR, "READY\n");
+fflush(STDERR);
+fgets(STDIN);
+';
+$proc6 = proc_open(
+ [PHP_BINARY, '-r', $code6],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes6,
+);
+$r0 = fgets($pipes6[2]);
+$terminal6 = Terminal::fromStreams($pipes6[0]);
+$token6 = $terminal6->enableRawMode();
+
+fwrite($pipes6[0], "START\n");
+$r1 = fgets($pipes6[2]);
+
+$secret6 = $terminal6->readSecret(Duration::fromSeconds(0));
+var_dump($secret6 === "zerosecret");
+
+fwrite($pipes6[0], "done\n");
+unset($terminal6);
+foreach ($pipes6 as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc6);
+
+// 7. Already-buffered standalone Escape consumed with zero timeout: follows cancellation contract and throws TerminalException
+$code7 = '
+fwrite(STDERR, "STARTED\n");
+fflush(STDERR);
+fgets(STDIN);
+
+fwrite(STDOUT, "\x1b");
+fflush(STDOUT);
+fwrite(STDERR, "READY\n");
+fflush(STDERR);
+fgets(STDIN);
+';
+$proc7 = proc_open(
+ [PHP_BINARY, '-r', $code7],
+ [
+ 0 => ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes7,
+);
+$r0 = fgets($pipes7[2]);
+$terminal7 = Terminal::fromStreams($pipes7[0]);
+$token7 = $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\Terminal::readSecret(): Argument #1 ($timeout) must not be negative
+bool(true)
+Zero timeout escape caught: Unable to read secret from terminal
diff --git a/ext/standard/tests/terminal/terminal_resize.phpt b/ext/standard/tests/terminal/terminal_resize.phpt
new file mode 100644
index 000000000000..f0fb31f81cb8
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_resize.phpt
@@ -0,0 +1,88 @@
+--TEST--
+Io\Terminal\Terminal: readKey detects resize and updates terminal size
+--SKIPIF--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+} catch (Throwable) {
+ die("skip PTY is not available");
+}
+if (!is_resource($proc)) {
+ die("skip PTY is not available");
+}
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--FILE--
+ ['pty'],
+ 1 => ['pty'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+);
+
+$r1 = fgets($pipes[2]);
+$terminal = Terminal::fromStreams($pipes[0]);
+$terminal->enableRawMode();
+
+$s1 = $terminal->getSize();
+echo "s1: {$s1->cols}x{$s1->rows}\n";
+
+// Signal child to change window size
+fwrite($pipes[0], "next\n");
+$r2 = fgets($pipes[2]);
+
+// readKey should detect size change and return Key::Resize
+$key = $terminal->readKey(Duration::fromSeconds(0));
+var_dump($key === Key::Resize);
+
+$s2 = $terminal->getSize();
+echo "s2: {$s2->cols}x{$s2->rows}\n";
+
+fwrite($pipes[0], "done\n");
+unset($terminal);
+foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) fclose($pipe);
+}
+proc_close($proc);
+?>
+--EXPECT--
+s1: 100x30
+bool(true)
+s2: 120x35
diff --git a/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt b/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt
new file mode 100644
index 000000000000..4fdab3f5ee64
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_secret_crlf_handoff.phpt
@@ -0,0 +1,189 @@
+--TEST--
+Io\Terminal\Terminal: CRLF consumption across secret boundaries and chunk splits
+--SKIPIF--
+
+--FILE--
+readSecret();
+$s2 = $term1->readSecret();
+$k1 = $term1->readKey();
+
+var_dump($s1 === "secretA");
+var_dump($s2 === "secretB");
+var_dump($k1 === "K");
+fclose($fp);
+
+// Part 2: Split CRLF across write chunks
+$pair = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+$readStream = $pair[0];
+$writeStream = $pair[1];
+$term2 = Terminal::fromStreams($readStream);
+
+// First write terminates with \r only
+fwrite($writeStream, "alpha\r");
+$sAlpha = $term2->readSecret();
+var_dump($sAlpha === "alpha");
+
+// Second write begins with the split \n, then beta\r\n, then trailing key
+fwrite($writeStream, "\nbeta\r\nQ");
+$sBeta = $term2->readSecret();
+var_dump($sBeta === "beta");
+
+$kQ = $term2->readKey(Duration::fromMilliseconds(100));
+var_dump($kQ === "Q");
+
+fclose($readStream);
+fclose($writeStream);
+
+// Part 3: Split CRLF followed by readLine()
+$pair3 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+$readStream3 = $pair3[0];
+$writeStream3 = $pair3[1];
+$term3 = Terminal::fromStreams($readStream3);
+
+fwrite($writeStream3, "password\r");
+$sPass = $term3->readSecret();
+var_dump($sPass === "password");
+
+// Next chunk arrives with split \n, then followed by line content and newline
+fwrite($writeStream3, "\nmy input line\n");
+$line3 = $term3->readLine();
+var_dump($line3 === "my input line");
+
+// Part 4: Chunk size 1, CR then later "next\n"
+$p4 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+stream_set_chunk_size($p4[0], 1);
+$t4 = Terminal::fromStreams($p4[0]);
+fwrite($p4[1], "password\r");
+var_dump($t4->readSecret() === "password");
+fwrite($p4[1], "next\n");
+var_dump($t4->readLine() === "next");
+fclose($p4[0]);
+fclose($p4[1]);
+
+// Part 5: Chunk size 1, CR then later "\nnext\n"
+$p5 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+stream_set_chunk_size($p5[0], 1);
+$t5 = Terminal::fromStreams($p5[0]);
+fwrite($p5[1], "password\r");
+var_dump($t5->readSecret() === "password");
+fwrite($p5[1], "\nnext\n");
+var_dump($t5->readLine() === "next");
+fclose($p5[0]);
+fclose($p5[1]);
+
+// Part 6: Chunk size 1, CR with next line already queued in same write
+$p6 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+stream_set_chunk_size($p6[0], 1);
+$t6 = Terminal::fromStreams($p6[0]);
+fwrite($p6[1], "password\rnext\n");
+var_dump($t6->readSecret() === "password");
+var_dump($t6->readLine() === "next");
+fclose($p6[0]);
+fclose($p6[1]);
+
+// Part 7: Chunk size 1, read "password\r", then supply "\r\nnext\n" -> readLine() returns "" and "next"
+$p7 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+stream_set_chunk_size($p7[0], 1);
+$t7 = Terminal::fromStreams($p7[0]);
+fwrite($p7[1], "password\r");
+var_dump($t7->readSecret() === "password");
+fwrite($p7[1], "\r\nnext\n");
+var_dump($t7->readLine() === "");
+var_dump($t7->readLine() === "next");
+fclose($p7[0]);
+fclose($p7[1]);
+
+// Part 8: A data CR before CRLF must survive split-prefix normalization.
+$p8 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+stream_set_chunk_size($p8[0], 1);
+$t8 = Terminal::fromStreams($p8[0]);
+fwrite($p8[1], "password\r");
+var_dump($t8->readSecret() === "password");
+fwrite($p8[1], "\r\r\nnext\n");
+var_dump($t8->readLine() === "\r");
+var_dump($t8->readLine() === "next");
+fclose($p8[0]);
+fclose($p8[1]);
+
+// Part 9: A bare saved CR at EOF remains unterminated line data.
+$p9 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+stream_set_chunk_size($p9[0], 1);
+$t9 = Terminal::fromStreams($p9[0]);
+fwrite($p9[1], "password\r");
+var_dump($t9->readSecret() === "password");
+fwrite($p9[1], "\r");
+fclose($p9[1]);
+var_dump($t9->readLine() === "\r");
+var_dump($t9->readLine() === null);
+fclose($p9[0]);
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_size.phpt b/ext/standard/tests/terminal/terminal_size.phpt
new file mode 100644
index 000000000000..76fb73d038b4
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_size.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Io\Terminal\Terminal: getSize returns null for non-terminal streams without environment fallback
+--ENV--
+COLUMNS=100
+LINES=30
+--FILE--
+getSize());
+
+?>
+--EXPECT--
+NULL
diff --git a/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt b/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt
new file mode 100644
index 000000000000..e7863379d966
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_stream_secret_controls.phpt
@@ -0,0 +1,119 @@
+--TEST--
+Io\Terminal\Terminal: stream readSecret controls, cancellation, navigation, and UTF-8 backspace
+--SKIPIF--
+
+--FILE--
+readSecret();
+ echo "FAIL: Ctrl+C returned secret: " . bin2hex($sec) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Ctrl+C cancelled\n";
+}
+
+// 2. Ctrl+D followed by newline: must cancel, not return secret with 0x04
+try {
+ $t = makeStreamTerminal("\x04\n");
+ $sec = $t->readSecret();
+ echo "FAIL: Ctrl+D returned secret: " . bin2hex($sec) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Ctrl+D cancelled\n";
+}
+
+// 3. Escape followed by newline: must cancel, not return secret with 0x1B
+try {
+ $t = makeStreamTerminal("\x1b\n");
+ $sec = $t->readSecret();
+ echo "FAIL: Escape returned secret: " . bin2hex($sec) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Escape cancelled\n";
+}
+
+// 4. Navigation sequence \x1b[A followed by text must ignore navigation and return text
+$t = makeStreamTerminal("\x1b[Aabc\n");
+$sec = $t->readSecret();
+var_dump($sec === "abc");
+
+// 5. UTF-8 backspace with chr(8) removes 3-byte Euro symbol
+$t = makeStreamTerminal("β¬" . chr(8) . "b\n");
+$sec = $t->readSecret();
+var_dump($sec === "b");
+
+// 6. ASCII backspace with 0x7F removes preceding byte
+$t = makeStreamTerminal("ab\x7fc\n");
+$sec = $t->readSecret();
+var_dump($sec === "ac");
+
+// 7. SS3 F1 followed by text must ignore F1 and return text (no 'P' corruption)
+$t = makeStreamTerminal("\x1bOPpass\n");
+$sec = $t->readSecret();
+var_dump($sec === "pass");
+
+// 8. CSI control cancellation: Ctrl+C inside CSI sequence must cancel
+try {
+ $t = makeStreamTerminal("\x1b[\x03pass\n");
+ $sec = $t->readSecret();
+ echo "FAIL: CSI Ctrl+C returned secret: " . bin2hex($sec) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: CSI Ctrl+C cancelled\n";
+}
+
+// 9. SS3 control cancellation: Ctrl+C inside SS3 sequence must cancel
+try {
+ $t = makeStreamTerminal("\x1bO\x03pass\n");
+ $sec = $t->readSecret();
+ echo "FAIL: SS3 Ctrl+C returned secret: " . bin2hex($sec) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: SS3 Ctrl+C cancelled\n";
+}
+
+// 10. Zero timeout standalone Escape followed by newline must cancel
+try {
+ $t = makeStreamTerminal("\x1b\n");
+ $sec = $t->readSecret(Duration::fromSeconds(0));
+ echo "FAIL: Zero timeout Escape returned secret: " . bin2hex($sec) . "\n";
+} catch (TerminalException $e) {
+ echo "PASS: Zero timeout Escape cancelled\n";
+}
+
+// 11. Zero timeout navigation sequence \x1b[A followed by text must ignore navigation and return text
+$t = makeStreamTerminal("\x1b[Apass\n");
+$sec = $t->readSecret(Duration::fromSeconds(0));
+var_dump($sec === "pass");
+
+// 12. Zero timeout SS3 F1 followed by text must ignore F1 and return text
+$t = makeStreamTerminal("\x1bOPpass\n");
+$sec = $t->readSecret(Duration::fromSeconds(0));
+var_dump($sec === "pass");
+
+?>
+--EXPECT--
+PASS: Ctrl+C cancelled
+PASS: Ctrl+D cancelled
+PASS: Escape cancelled
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+PASS: CSI Ctrl+C cancelled
+PASS: SS3 Ctrl+C cancelled
+PASS: Zero timeout Escape cancelled
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt b/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt
new file mode 100644
index 000000000000..2c6d2b8663a4
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_stream_sequence_timeout.phpt
@@ -0,0 +1,129 @@
+--TEST--
+Io\Terminal\Terminal: ordinary-stream sequence timeout on partial Escape/CSI/UTF-8
+--SKIPIF--
+
+--FILE--
+readKey(Duration::fromSeconds(0), Duration::fromSeconds(0));
+$elapsed0 = (microtime(true) - $t0) * 1000;
+var_dump($key0 === Key::Escape);
+var_dump($elapsed0 < 100);
+
+// Test 2: Standalone Escape with sequence timeout returns Escape after sequence delay, not blocking forever
+fwrite($writeStream, "\x1b");
+$t1 = microtime(true);
+$key1 = $term->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(60));
+$elapsed1 = (microtime(true) - $t1) * 1000;
+var_dump($key1 === Key::Escape);
+var_dump($elapsed1 >= 40 && $elapsed1 < 400);
+
+// Test 3: Partial CSI "\x1b[" on pipe returns after sequence timeout
+fwrite($writeStream, "\x1b[");
+$t2 = microtime(true);
+$key2 = $term->readKey(Duration::fromMilliseconds(500), Duration::fromMilliseconds(60));
+$elapsed2 = (microtime(true) - $t2) * 1000;
+var_dump($key2 === "\x1b[");
+var_dump($elapsed2 >= 40 && $elapsed2 < 400);
+
+// Test 4: Incomplete UTF-8 prefix "\xE2" times out within deadline and retains prefix
+fwrite($writeStream, "\xE2");
+$t3 = microtime(true);
+$key3 = $term->readKey(Duration::fromMilliseconds(60));
+$elapsed3 = (microtime(true) - $t3) * 1000;
+var_dump($key3 === null);
+var_dump($elapsed3 >= 40 && $elapsed3 < 400);
+
+// Supply completion bytes
+fwrite($writeStream, "\x82\xAC");
+$key4 = $term->readKey(Duration::fromMilliseconds(100));
+var_dump($key4 === "β¬");
+
+fclose($readStream);
+fclose($writeStream);
+
+// Test 5: Incomplete CSI sequence in readSecret times out within overall deadline
+$pair5 = stream_socket_pair(
+ PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX,
+ STREAM_SOCK_STREAM,
+ PHP_OS_FAMILY === 'Windows' ? STREAM_IPPROTO_IP : 0
+);
+$read5 = $pair5[0];
+$write5 = $pair5[1];
+$term5 = Terminal::fromStreams($read5);
+
+fwrite($write5, "\x1b[1;2;3");
+$t5 = microtime(true);
+$sec5 = $term5->readSecret(Duration::fromMilliseconds(150));
+$elapsed5 = (microtime(true) - $t5) * 1000;
+var_dump($sec5 === null);
+var_dump($elapsed5 >= 100 && $elapsed5 < 600);
+
+fclose($read5);
+fclose($write5);
+
+// Test 6: A producer continuously supplies non-final CSI bytes for roughly two seconds;
+// readSecret with a 150ms timeout must return null well before producer finishes
+$proc6 = proc_open(
+ [PHP_BINARY, "-n", "-r", "fwrite(STDOUT, \"\\x1b[\"); \$t = microtime(true); while (microtime(true) - \$t < 2.0) { fwrite(STDOUT, \"1;2\"); usleep(5000); } fwrite(STDOUT, \"m\");"],
+ [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]],
+ $pipes6
+);
+
+if (is_resource($proc6)) {
+ $read6 = $pipes6[1];
+ $term6 = Terminal::fromStreams($read6);
+ $t6 = microtime(true);
+ $sec6 = $term6->readSecret(Duration::fromMilliseconds(150));
+ $elapsed6 = (microtime(true) - $t6) * 1000;
+ var_dump($sec6 === null);
+ var_dump($elapsed6 >= 100 && $elapsed6 < 800);
+ fclose($pipes6[0]);
+ fclose($pipes6[1]);
+ fclose($pipes6[2]);
+ proc_terminate($proc6);
+ proc_close($proc6);
+}
+
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/standard/tests/terminal/terminal_stream_timeout.phpt b/ext/standard/tests/terminal/terminal_stream_timeout.phpt
new file mode 100644
index 000000000000..0373a27d0684
--- /dev/null
+++ b/ext/standard/tests/terminal/terminal_stream_timeout.phpt
@@ -0,0 +1,121 @@
+--TEST--
+Io\Terminal\Terminal: ordinary-stream timeout on empty pipe with open writer
+--SKIPIF--
+
+--FILE--
+readKey(Duration::fromSeconds(0));
+$elapsed0 = (microtime(true) - $t0) * 1000;
+var_dump($key0 === null);
+var_dump($elapsed0 < 200);
+
+// Test 2: Empty pipe with finite timeout returns null after bounded wait
+$t1 = microtime(true);
+$key1 = $term->readKey(Duration::fromMilliseconds(50));
+$elapsed1 = (microtime(true) - $t1) * 1000;
+var_dump($key1 === null);
+var_dump($elapsed1 >= 30 && $elapsed1 < 300);
+
+// Test 3: Queued byte is immediately readable
+fwrite($writeStream, "Z");
+$t2 = microtime(true);
+$key2 = $term->readKey(Duration::fromMilliseconds(200));
+$elapsed2 = (microtime(true) - $t2) * 1000;
+var_dump($key2 === "Z");
+var_dump($elapsed2 < 100);
+
+// Test 4: readSecret on empty pipe with finite timeout returns null after bounded wait
+$t3 = microtime(true);
+$sec3 = $term->readSecret(Duration::fromMilliseconds(50));
+$elapsed3 = (microtime(true) - $t3) * 1000;
+var_dump($sec3 === null);
+var_dump($elapsed3 >= 30 && $elapsed3 < 300);
+
+fclose($readStream);
+fclose($writeStream);
+
+// Part 2: Anonymous pipe via proc_open
+$proc = proc_open(
+ [PHP_BINARY, '-r', 'usleep(2000000);'],
+ [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $procPipes
+);
+if (!is_resource($proc)) {
+ die("Failed to proc_open child");
+}
+
+$pipeTerm = Terminal::fromStreams($procPipes[1]);
+
+// Test 5: Empty anonymous pipe with zero timeout returns null immediately (not blocking)
+$t4 = microtime(true);
+$key4 = $pipeTerm->readKey(Duration::fromSeconds(0));
+$elapsed4 = (microtime(true) - $t4) * 1000;
+var_dump($key4 === null);
+var_dump($elapsed4 < 200);
+
+// Test 6: Empty anonymous pipe with finite timeout returns null after bounded wait
+$t5 = microtime(true);
+$key5 = $pipeTerm->readKey(Duration::fromMilliseconds(50));
+$elapsed5 = (microtime(true) - $t5) * 1000;
+var_dump($key5 === null);
+var_dump($elapsed5 >= 30 && $elapsed5 < 300);
+
+// Test 7: readSecret on empty anonymous pipe with finite timeout returns null after bounded wait
+$t6 = microtime(true);
+$sec6 = $pipeTerm->readSecret(Duration::fromMilliseconds(50));
+$elapsed6 = (microtime(true) - $t6) * 1000;
+var_dump($sec6 === null);
+var_dump($elapsed6 >= 30 && $elapsed6 < 300);
+
+fclose($procPipes[0]);
+fclose($procPipes[1]);
+fclose($procPipes[2]);
+proc_terminate($proc);
+proc_close($proc);
+
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)