[PHP-DEV] [RFC] Io\Terminal

Hi all,

Sorry for the delay. I took some time to think through the testing and ownership points and verify the changes on both platforms.

I have updated the RFC and implementation to v0.4:

https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941

Ilia wrote:

I agree with Tim and Bob: drop both interfaces and rename SystemTerminal to Terminal.

This is now implemented. Terminal and ModeToken are final native classes, with one Terminal implementation and the existing fromStdio() and fromStreams() factories.

Larry, the scripted-stream approach now works for key and secret reads as well as line reads. readKey() decodes ordinary byte streams on both platforms, readSecret() accepts scripted input, and enableRawMode() returns a no-op lease for non-terminal streams. This gives application input tests a straightforward PHP-stream approach without adding interfaces to core. Native dimensions, resize events and mode failures still need terminal integration tests.

Tim, your point about keeping Windows test infrastructure separate from the long-term API design helped clarify this. I also investigated create_new_console on Windows 11 ARM64. With standard input redirected to a pipe, the child still reads that pipe; creating a console does not make those bytes native console key events. Opening CONIN$/CONOUT$ gives access to console handles, but writing bytes with fwrite() does not inject input records. The native console checks therefore used WriteConsoleInputW() through a test harness. That distinction no longer dictates the public API: ordinary streams support application input tests on Windows too.

Ilia’s other points are addressed:

  • Raw-mode acquisition preserves POSIX output settings, including OPOST. The RFC now specifies the observable input behavior.
  • Leases record the acquiring process. A forked child’s cleanup does not restore the parent’s terminal mode; explicit restoration of an inherited parent token is rejected.
  • Terminal no longer retains the latest token. restoreMode() requires a token and returns void. Normal completion means the lease was released; failures throw. An explicit final restoration failure leaves the token active for a retry.
  • Key::Insert and Key::ShiftTab are included.

Nicolas’s shared logical-terminal lease model is retained: the first acquisition saves the mode, overlapping leases can be released in any order, and the final release restores the saved state. Cross-wrapper restoration for the same terminal also remains supported.

readLine() keeps its limited scope, without a portable editor or timeout parameter. It does not override an outstanding native raw-mode lease. The output stream remains useful for geometry and resize detection; application output continues through normal stream APIs.

Local testing covered macOS and Windows 11 ARM64, including native Windows console input, Unicode and mode restoration. The PR also includes regressions for input transitions, split CRLF handling and timeout behavior.

Thanks to everyone for working through this. I would like to move this revision toward voting after allowing time to review these changes. Please flag any remaining concerns with the public contract or implementation.

Best regards,
Pratik

Hi,

On Sun, Sep 27, 2026 at 7:21 PM Pratik Bhujel <prateekbhujelpb@gmail.com> wrote:

Hi internals,

I would like to formally propose the Io\Terminal API for PHP 8.7:

https://wiki.php.net/rfc/io_terminal

In general it’s a nice RFC.

I wonder if we should have some stream abstraction. So far, streams are not directly in Io as we are dealing with underlying fds that are abstracted through Poll\Handle implementations which is also used by IO hooks for direct rw. It has got advantages that it can support more than just streams. In this case I’m not sure more than streams is needed but I feel it would still be nicer to have some sort of abstraction there. I wouldn’t see it as a blocker, more just something to consider and think about.

Kind regards,

Jakub

Hi Jakub,

I wonder if we should have some stream abstraction.

Thanks for taking a look. I see the benefit of having something shared across Io, especially if it can support sources beyond streams.

With fromStreams(), one thing I would want to preserve is the supplied stream’s buffering and input behavior, including support for php://memory. We also need access to the native terminal capabilities for mode changes and Windows console input.

For this RFC, I would lean towards keeping fromStdio() and fromStreams(). If a suitable shared abstraction becomes available, we could add another factory for it while keeping these entry points. The ownership and buffering rules would still need to be worked through.

Did you have a particular abstraction in mind? I would be interested in understanding whether it would improve the current API, or whether keeping these factories leaves us enough room to integrate it later.

Regards,
Pratik

Hi all,

I realised I had left the voting widget out of the RFC, so I’ve now added it. The API is unchanged.

https://wiki.php.net/rfc/io_terminal

Sorry for missing this earlier.

Regards,
Pratik

Hi all,

I plan to open voting on the Io\Terminal RFC on Friday, October 23.

https://wiki.php.net/rfc/io_terminal

If there are any further questions or concerns, please let me know.

Regards,
Pratik

Hi

On 10/21/26 14:32, Pratik Bhujel wrote:

I plan to open voting on the Io\Terminal RFC on Friday, October 23.

PHP: rfc:io_terminal

If there are any further questions or concerns, please let me know.

Large parts of the core team have been busy in the past week(s) due to today's security release. I assume not everyone has been able to catch up with RFCs in that time (me included, I did not get around to reading the latest iteration of your - non-trivial - RFC).

I suggest to wait with the vote for things to have calmed down.

Best regards
Tim Düsterhus

Hi Tim,

I suggest to wait with the vote for things to have calmed down.

Understood, sorry about the timing. I’ll hold off on the October 23 vote and give everyone more time to review. I’ll send a fresh Intent to Vote notice once things have settled down.

Regards,
Pratik

On Sun, Sep 27, 2026, at 19:19, Pratik Bhujel wrote:

I would like to formally propose the Io\Terminal API for PHP 8.7:

https://wiki.php.net/rfc/io_terminal

Thank you. Nice work.

EOF before submission, cancellation, and operational failures throw TerminalException.

Perhaps it would be desirable to distinguish between the user cancelling, and a genuine error, and perhaps also EOF; for example with subclasses of TerminalException.

The default for a blocking read is 25 milliseconds.

Is this sufficiently long, even when used over SSH or such? Is this configurable in readSecret as well?

A resize is returned as Key::Resize.

If I resize the terminal twice, do I get two Key::Resize? getSize() seems to update the stored size of the terminal. Do I still get a Key::Resize after I call getSize()?

$timeout sets the overall timeout: zero: make a non-blocking attempt

This would not work with escape sequences, right? Or does it work correctly if the key is already buffered?

Regards,

Sjoerd Langkemper

Hi Sjoerd,

Thanks for taking a look.

Perhaps it would be desirable to distinguish between the user cancelling, and a genuine error, and perhaps also EOF; for example with subclasses of TerminalException.

Yes, I see the use case. An application may handle a cancelled password prompt quietly but report an I/O failure. Currently they all throw TerminalException, and the messages are not stable identifiers. Separate subclasses would allow that distinction while keeping TerminalException as the common catch. I would like to settle this before the vote.

Is this sufficiently long, even when used over SSH or such? Is this configurable in readSecret as well?

The 25 ms starts after Escape arrives, so it is not a limit on the SSH round trip. Delayed fragments can still exceed it. readKey() lets you increase sequenceTimeout, within the overall timeout.

readSecret() currently uses a fixed 25 ms wait in its byte-stream escape handling. It is not separately configurable, and increasing the overall timeout does not increase that wait. That is a limitation worth addressing. Native Windows console input uses input records, so this escape-sequence timing does not apply there.

If I resize the terminal twice, do I get two Key::Resize? getSize() seems to update the stored size of the terminal. Do I still get a Key::Resize after I call getSize()?

Not necessarily two. On POSIX, resize notifications can be coalesced, and changes between reads can be detected by comparing dimensions.

You are right about getSize(). It updates the same cached dimensions, so calling it first can hide a change from that comparison. Windows can still have queued resize records. I think getSize() should query the size without acknowledging a pending notification. That needs fixing and documenting. Resize should mean “check the current size”, rather than promise a count of every resize.

This would not work with escape sequences, right? Or does it work correctly if the key is already buffered?

Reading the current byte-stream decoder, the zero deadline makes it return Escape before checking the remaining bytes, even if the whole sequence is buffered. The focused zero-timeout test covers a lone Escape.

Zero should prevent waiting while still allowing a complete available sequence to be decoded. That needs a focused regression test and a fix.

I’ll follow up with the test results and changes. These findings are from checking the code; I haven’t run the new reproducers yet.

Regards,
Pratik