Add Io\Terminal native terminal primitives - #23941
prateekbhujel wants to merge 38 commits into
Conversation
| // Key identity and comparison | ||
| var_dump(Key::Up === Key::Up); | ||
| var_dump(Key::Up !== Key::Down); |
There was a problem hiding this comment.
This is just testing that enums work. Overall this test doesn't seem to be particularly useful.
| static zend_class_entry *php_io_poll_watcher_class_entry; | ||
| static zend_class_entry *php_io_poll_handle_class_entry; | ||
| static zend_class_entry *php_io_exception_class_entry; | ||
| PHPAPI zend_class_entry *php_io_exception_class_entry; |
There was a problem hiding this comment.
This would should be moved out of io_poll.c as a separate PR.
| HANDLE handle = input; | ||
| DWORD mode; | ||
| WCHAR high_surrogate = 0; | ||
| DWORD raw_mode; | ||
| smart_str secret = {0}; | ||
| bool success = false; | ||
| bool failed = false; | ||
| bool mode_changed = false; |
There was a problem hiding this comment.
Reduce the scope of variables as much as possible.
|
|
||
| // Zero duration poll | ||
| $res = $t->readKey(Duration::fromSeconds(0)); | ||
| var_dump($res === false || is_string($res) || is_object($res)); |
There was a problem hiding this comment.
Is this not just testing the return type is correct? Make this expectation stronger (or remove it).
|
|
||
| // Named constructors | ||
| $t1 = Terminal::create(); | ||
| var_dump($t1 instanceof Terminal); |
There was a problem hiding this comment.
| var_dump($t1 instanceof Terminal); | |
| var_dump($t1); |
- Coordinate POSIX raw mode token ownership by underlying terminal device identity (tty_dev) so overlapping sessions and multiple descriptors reliably restore canonical mode. - Forward SIGWINCH to existing signal handlers and track terminal dimensions on Terminal instances to detect between-read resizes. - Allow zero-duration readKey() to return immediately without waiting for sequence timeout. Clamp finite timeouts and guard time_t conversions. - Validate continuation bytes during UTF-8 secret reading and dispatch non-continuation control bytes to preserve Ctrl+C cancellation. - Preserve high surrogate across repeated Windows console input events. - Replace synthetic non-TTY test fallbacks with genuine PTY tests and add regression tests for non-TTY streams, timeouts, secret controls, and resizes.
| memcpy(saved->magic, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC, PHP_IO_TERMINAL_MODE_TOKEN_MAGIC_LEN); | ||
| saved->stream = fd; | ||
| saved->mode = mode; | ||
| saved->tty_dev = st.st_rdev; |
There was a problem hiding this comment.
st_rdev isn't a reliable terminal identity: it's too strict for /dev/tty (5:0 vs 136:N for the same terminal) and too loose for pty masters (all 5:2, so two unrelated proc_open() ptys are considered the same terminal, and releasing the first token leaves the first pty in raw mode).
A portable way to get a stable identity:
tcgetsid(fd) == getsid(0)means fd is our controlling terminal, whatever path it was opened from (/dev/tty,/dev/pts/N, stdin) - a process has at most one;- otherwise,
ptsname(fd)is non-NULL for a pty master (ptsname_r()for ZTS), andstat()on it gives the slave's st_rdev; - otherwise, st_rdev is reliable.
On Linux, ioctl(fd, TIOCGDEV) does all of this in one call (136:N for both /dev/tty and a pty master).
(same for the checks in php_io_terminal_mode_token_stream_is_valid())
There was a problem hiding this comment.
Hi @nicolas-grekas , Thanks so much for the guidance! I've refactored raw mode to use your proposed shared-record refcounting model with an independent restore descriptor and the updated POSIX identity checks. Everything is now order-independent and survives closed streams and destroyed Terminal objects as suggested.
TimWolla
left a comment
There was a problem hiding this comment.
I'm only remarking the first occurrence of any given point. Please carefully check if the same suggestion would apply elsewhere for everything.
| RETURN_THROWS(); | ||
| } | ||
|
|
||
| zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(ZEND_THIS), "cols", sizeof("cols") - 1, cols); |
There was a problem hiding this comment.
You need to check for exceptions after this call, the assigning will fail when calling the constructor on an already-constructed object and then trying to assign to rows will fail again and and throw another exception.
See ZEND_METHOD(Deprecated, __construct) for an example of how to make the check.
There was a problem hiding this comment.
Added if (UNEXPECTED(EG(exception))) { RETURN_THROWS(); } after each readonly property assignment in TerminalSize::__construct(), matching Deprecated::__construct(). Re-invoking the constructor on an initialized instance now terminates immediately on the $cols modification error without attempting subsequent assignments.
| static void php_io_terminal_create_terminal_size(zval *return_value, zend_long cols, zend_long rows) | ||
| { | ||
| object_init_ex(return_value, php_io_terminal_terminal_size_ce); | ||
| zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(return_value), "cols", sizeof("cols") - 1, cols); | ||
| zend_update_property_long(php_io_terminal_terminal_size_ce, Z_OBJ_P(return_value), "rows", sizeof("rows") - 1, rows); | ||
| } |
There was a problem hiding this comment.
Consider object_init_with_constructor() instead of a custom helper.
There was a problem hiding this comment.
Removed the internal initialization helper; Terminal::getSize() now instantiates TerminalSize via object_init_with_constructor().
| int fd = input; | ||
| struct termios mode; | ||
| struct termios raw_mode; | ||
| smart_str secret = {0}; | ||
| bool success = false; | ||
| bool mode_changed; | ||
| php_poll_ctx *poll_ctx; |
There was a problem hiding this comment.
Narrowing the scope is possible for a number of variables here (probably also in other functions).
There was a problem hiding this comment.
Narrowed variable declarations to their point of first use throughout io_terminal.c.
| #if SIZEOF_TIME_T < 8 | ||
| if (timeout_duration->duration.seconds > PHP_IO_TERMINAL_TIME_T_MAX) { | ||
| timeout.tv_sec = PHP_IO_TERMINAL_TIME_T_MAX; | ||
| } else { | ||
| timeout.tv_sec = (time_t) timeout_duration->duration.seconds; | ||
| } | ||
| #else | ||
| timeout.tv_sec = (time_t) timeout_duration->duration.seconds; |
There was a problem hiding this comment.
We don't have any other SIZEOF_TIME_T in the codebase, is this an issue that also affects the polling API? Should an exception being thrown for out-of-range Durations instead of silently clamping them?
There was a problem hiding this comment.
Replaced #if SIZEOF_TIME_T < 8 with a static inline clamping helper using sizeof(time_t) < 8.
Regarding clamping vs throwing: php_poll.c / io_poll.c takes the same approach when a Duration overflows INT_MAX milliseconds, capping rather than throwing because a timeout exceeding the platform limit is practically indefinite. The clamping helper keeps that behavior consistent between the two I/O extensions.
| #if !defined(PHP_WIN32) && defined(SIGWINCH) && defined(ZTS) | ||
| php_io_terminal_resize_mutex = tsrm_mutex_alloc(); | ||
| #endif | ||
| #if !defined(PHP_WIN32) && !defined(PHP_IO_TERMINAL_HAVE_PTSNAME_R) && defined(ZTS) | ||
| php_io_terminal_ptsname_mutex = tsrm_mutex_alloc(); | ||
| #endif |
There was a problem hiding this comment.
For readability it probably makes sense to poll the ZTS check to the beginning of the condition (or as a separate condition wrapping the internal conditions).
There was a problem hiding this comment.
Grouped the ZTS checks into outer #ifdef ZTS blocks in MINIT/MSHUTDOWN and simplified the mutex lock/unlock helpers into clean macros.
- Check EG(exception) after readonly property assignment in TerminalSize::__construct - Instantiate TerminalSize with object_init_with_constructor in Terminal::getSize - Match POSIX/Windows terminal identities by unique device/handle, avoiding raw descriptor reuse - Verify actual terminal state after tcsetattr and GetConsoleMode - Protect readSecret against NULL deref on disconnect, abort on control/escape sequences, and preserve fragmented UTF-8 across boundaries - Account for timeouts with zend_hrtime monotonic deadline across escape and multibyte decoding - Narrow variable declarations and simplify ZTS mutex macro guards
…cret timeouts - Fix cancellation inside readSecret() escape parsing: - Abort on Ctrl+C, Ctrl+D, Escape, Enter inside CSI and SS3 sequences via terminal restoration path. - Distinguish cancellation, EOF, internal 25ms sequence timeout (which ends sequence skipping and continues secret input), and overall secret timeout. - Discard valid CSI (e.g. arrow keys) and SS3 (e.g. F1) sequences without exiting or emitting garbage. - Enforce one monotonic read deadline in readKey(): - Cap sequence timeout and UTF-8 continuation reads by remaining overall budget. - Zero timeout executes non-blocking read without blocking wait even if sequenceTimeout is specified. - Incomplete escape sequences return raw byte strings; incomplete UTF-8 sequences retain pending bytes across calls and return null on timeout. - Adopt null for absence: - Update Terminal::getSize() return type to ?TerminalSize. - Update Terminal::readKey() return type to Key|string|null. - Keep Terminal::restoreMode() return type as bool. - Add optional overall timeout to Terminal::readSecret(?Time\Duration $timeout = null): - Returns null on timeout, '' on empty submission (immediate Enter). - Throws TerminalException on cancellation/failure. - Throws ValueError on negative duration. - Introduce mockable boundaries: - Add Io\Terminal\TerminalInterface and Io\Terminal\ModeTokenInterface. - Make native Terminal implement TerminalInterface and ModeToken implement ModeTokenInterface while remaining final. - Have native restoreMode(?ModeTokenInterface $mode = null) validate native ModeToken, rejecting foreign tokens with ValueError. - Add comprehensive regression and acceptance tests.
DanielEScherzer
left a comment
There was a problem hiding this comment.
some initial thoughts
| # define PATH_MAX 1024 | ||
| #endif | ||
|
|
||
| static bool php_io_terminal_get_slave_dev_from_pty_master(int fd, dev_t *slave_dev) |
There was a problem hiding this comment.
please use more inclusive names in new code, i.e. avoid "slave" and "master"
There was a problem hiding this comment.
Updated the new PTY identifiers to neutral terminology; the platform ptsname*() APIs remain unchanged.
| if (GetConsoleMode(shared->restore_stream, &actual_mode) && actual_mode != shared->saved_mode) { | ||
| return false; | ||
| } | ||
| return true; |
There was a problem hiding this comment.
both blocks end with return true, maybe move that out of the preprocessor conditional?
There was a problem hiding this comment.
Done — moved the common return true outside the conditional.
|
|
||
| if (intern->valid && intern->shared != NULL) { | ||
| const char *err = NULL; | ||
| if (!php_io_terminal_release_token_lease(intern, &err)) { |
There was a problem hiding this comment.
this error isn't used for anything, so can just pass NULL
There was a problem hiding this comment.
Updated — passing NULL directly since the error output isn't used here.
| WCHAR *pending_key_high_surrogate | ||
| ) | ||
| { | ||
| DWORD records_read; |
There was a problem hiding this comment.
not needed for a while, suggest declaring lower down
There was a problem hiding this comment.
Updated — the declaration now sits at first use.
| HANDLE handle = input; | ||
| DWORD mode = 0; | ||
| WCHAR high_surrogate = *pending_high_surrogate; | ||
| DWORD raw_mode; |
There was a problem hiding this comment.
some of these are not needed for a while, e.g. raw_mode can be declared as part of its initialization on 998
also applies in a number of other places, not going to point out each time
There was a problem hiding this comment.
Narrowed raw_mode, key, and repeats to their points of initialization.
| } | ||
| mode_changed = raw_mode != mode; | ||
|
|
||
| for (;;) { |
There was a problem hiding this comment.
probably clearer to use
| for (;;) { | |
| while (true) { |
There was a problem hiding this comment.
Updated to while (true).
|
|
||
| pending->bytes[pending->length++] = key; | ||
| if ((key & 0xc0) != 0x80) { | ||
| zend_string *invalid = zend_string_init((const char *) pending->bytes, pending->length, false); |
There was a problem hiding this comment.
I think this is just the same result as a break here
There was a problem hiding this comment.
Yep, this reaches the same common cleanup/return path. Simplified it to break.
| #define PHP_IO_TERMINAL_CSI_IS(literal) \ | ||
| (seq_len == sizeof(literal) - 1 && memcmp(seq, literal, sizeof(literal) - 1) == 0) | ||
|
|
||
| if (PHP_IO_TERMINAL_CSI_IS("\x1b[A")) return ZSTR_INIT_LITERAL("up", false); |
There was a problem hiding this comment.
a bunch of these have the same length in the comparison, and so this is going to have a whole bunch of unneeded comparisons of the seq_len vs the potential key. Maybe separate them out?
#define PHP_IO_TERMINAL_CSI_IS(literal) (memcmp(seq, literal, strlen(literal) == 0)
if (seq_len == strlen("\x1b[B")) {
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);
// ...
} else if (seq_len == strlen("\x1b[3~")) {
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 == strlen("\x1b[11~")) {
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);
// ...
}
but then the memory comparison against \x1b[ is repeated a whole bunch, and this can be further optimized
not urgent since this still needs an RFC, just noting
There was a problem hiding this comment.
Grouped the known sequences by length so we avoid repeating the length comparison for every candidate. The mappings and fallback behavior are unchanged.
| * @generate-c-enums | ||
| */ | ||
|
|
||
| namespace Io\Terminal { |
There was a problem hiding this comment.
you can just have a semicolon declaration and then don't need to indent everything
There was a problem hiding this comment.
Changed the stub to the semicolon namespace form and regenerated arginfo.
There was a problem hiding this comment.
I would recommend against this change: It adds unnecessary churn when later introducing sub-namespaces. For ext/date/time.stub.php I specifically started using the brace version right away.
There was a problem hiding this comment.
Ah, I hadn’t considered the future sub-namespace case when I applied Daniel’s suggestion. I was looking at it as a straightforward cleanup for the current single namespace. The ext/date/time.stub.php precedent makes the reason for keeping the braces clear. I’ve switched it back and regenerated the generated headers.
|
FWIW the timespec functions may be useful to extract into another utility file in the future. poll has Also, perhaps it might be useful to split the Unix/Windows backends into their own files, if it gets unwieldy. |
|
Yeah, both make sense. I kept them local for now since the terminal implementation is still fairly self-contained, but if the timespec helpers start being reused elsewhere, pulling them into a shared utility would be cleaner. Same for the platform code, if the Unix/Windows sides keep growing, splitting them out would probably make the implementation easier to maintain. Thanks for pointing it out. |
Implementation of the proposed PHP 8.7
Io\TerminalRFC, with the API proposed for version 0.3:https://wiki.php.net/rfc/io_terminal
This adds native terminal primitives to
ext/standard, alongsideIo\Poll, using POSIX terminal APIs and the Windows Console API.The API provides:
TerminalandModeTokeninterfaces for userland implementations and test doubles.SystemTerminalimplementation, created throughfromStdio()orfromStreams($input, $output = null).TerminalSize, a directly constructible readonly value object, andgetSize(): ?TerminalSize, withoutCOLUMNS/LINESfallbacks.enableRawMode(): SystemModeTokenandrestoreMode(?ModeToken $mode = null): bool.readKey(?Time\Duration $timeout = null, ?Time\Duration $sequenceTimeout = null): Key|string|null.readLine(): ?string.readSecret(?Time\Duration $timeout = null): ?string.TerminalExceptionfor operational failures.Raw-mode ownership is coordinated per logical terminal within a PHP request. Leases can be released in any order, including through another wrapper for the same terminal; the final release restores the saved mode using an independent native descriptor or handle. Native restoration rejects foreign, consumed, or unrelated tokens. A no-argument
restoreMode()returnsfalsewhen the wrapper has no active retained token.readKey()andreadSecret()manage their own temporary terminal mode. Explicit raw-mode leases support longer interactive sessions.readLine()has no timeout or line-editor features. It preserves spaces and tabs, removes LF/CRLF terminators, returnsnullon immediate EOF, and returns a final unterminated line at EOF. POSIX terminal reads use the existing line discipline and wait for readability afterEAGAIN/EWOULDBLOCKwithout changing descriptor flags. Windows console reads use native line input and restore the previous console mode. Redirected PHP streams retain their buffering, blocking, and read-timeout behavior. Line reads reject an active managed raw-mode lease for the same logical terminal, even through another wrapper.Key and secret reads return
nullon timeout; key-input EOF and secret cancellation throwTerminalException. On POSIX, incomplete UTF-8 bytes are retained across key-read timeouts and prepended when line reading continues. Escape ambiguity is separate: a standalone Escape becomesKey::Escape, while a consumed incomplete escape sequence may be returned as a prefix string.PHPT coverage in
ext/standard/tests/terminal/includes contracts and test doubles, redirected and buffered streams, key decoding, timeouts, secret input, resize handling, and POSIX PTY cases for shared leases, token lifetime, cross-wrapper restoration, unrelated terminals, EOF, and nonblocking line input. The Windows-specific line-input test exercises redirected streams; nativeReadConsoleW()validation remains needed.The implementation remains under review. Platform CI results are tracked on this pull request.
Reference extension and prior ecosystem work:
https://github.com/prateekbhujel/php-terminal