Hi internals,
I would like to formally propose the Io\Terminal API for PHP 8.7:
https://wiki.php.net/rfc/io_terminal
This follows the earlier php-internals discussion about native
terminal helpers and the API work that came out of it.
The RFC proposes a small Io\Terminal API in ext/standard for terminal
sessions, native terminal size queries, raw mode with safe
restoration, normalized key input, and hidden input.
The existing reference implementation is ext-terminal, distributed
through PIE/Packagist as prateekbhujel/php-terminal:
https://github.com/prateekbhujel/php-terminal
A draft php-src implementation is available here:
https://github.com/php/php-src/pull/23941
It follows the reduced core API rather than copying the full
ext-terminal 1.x surface.
The proposed core API uses Time\Duration for timeout parameters, does
not expose a public constructor, and leaves compatibility aliases,
environment-based size fallbacks, and convenience APIs out of the
initial core proposal.
The earlier internals discussion and the Symfony Console/TUI
integration have helped narrow the proposal to the parts that require
native cross-platform support. Some of the earlier replies were
accidentally split into separate archive threads, so the RFC collects
the full prior discussion history in one place.
I would appreciate feedback on the proposed API and semantics before voting.
Thanks,
Pratik
Hi
I would like to formally propose the Io\Terminal API for PHP 8.7:
Thank you for the RFC. Some questions to start of the discussion:
-
Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes. -
It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC. -
Terminal::create() should probably be ::fromStdio() or similar.
-
I'm not sure about false vs Exception for the various methods.
enableRawMode()should probably be Exception, forreadKey()the
falsereturn is not explained. Also the behavior of what happens when
a timeout strikes is not explained. -
Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Best regards
Tim Düsterhus
Hi Pratik,
thanks for the RFC, PHP definitely needs native terminal support, calling
stty is a workaround we've been carrying since way too long.
Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus tim@bastelstu.be a écrit :
Hi
I would like to formally propose the Io\Terminal API for PHP 8.7:
Thank you for the RFC. Some questions to start of the discussion:
Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes.It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC.Terminal::create() should probably be ::fromStdio() or similar.
I'm not sure about false vs Exception for the various methods.
enableRawMode()should probably be Exception, forreadKey()the
falsereturn is not explained. Also the behavior of what happens when
a timeout strikes is not explained.Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Related to ModeToken, what about a refcount instead? One record per
terminal, shared by all its tokens: the first enableRawMode() saves the
original termios and dup()s the fd into the record, the next ones only
increment a counter, and releasing any token decrements it. At zero, the
original state is restored through the record's own fd. That's
order-independent by construction and immune to fclose(). The RFC could
then spec it in one sentence: raw mode stays on as long as one token for
that terminal is alive, and the state from before the first token is
restored when the last one goes away. Today it says a token "restores its
saved terminal mode when it is destroyed", which isn't what happens out of
order. If more modes are added later, the record can keep the set of active
requests and recompute the target state from the original on each change.
Cheers,
Nicolas
Hi Tim, Nicolas,
Thanks both for the feedback. I went through the API and the
implementation again and updated the RFC and PR to reflect it.
For Tim's points:
- TerminalSize now has a normal public constructor, with positive
dimensions enforced. - The RFC now shows the actual stub, including strict-properties /
non-serializable declarations. - Terminal::create() has been renamed to Terminal::fromStdio().
- enableRawMode() now returns ModeToken and uses TerminalException for
operational/native failures. - readKey() now has a defined contract: false means timeout/no
complete key before the deadline; non-TTY input, EOF and
native/polling failures are exceptions. The timeout and
sequenceTimeout behavior is now described explicitly in the RFC. - ModeToken lifetime and restoreMode() behavior are now described in detail.
For the ownership question, I kept ModeToken opaque rather than adding
a public Terminal back-reference.
Nicolas, I also changed the implementation to the shared-per-terminal
model you suggested. The first active token captures the original mode
and owns the restoration descriptor/handle, subsequent tokens share
that state, and the original mode is restored when the last active
token is released. This makes release order irrelevant and also means
closing the original PHP stream does not prevent restoration.
I also reworked the terminal identity handling from the earlier
st_rdev-only approach so different descriptors for the same logical
terminal can coordinate without conflating unrelated PTYs.
The RFC has been updated here:
https://wiki.php.net/rfc/io_terminal
Implementation:
https://github.com/php/php-src/pull/23941
These are substantive API/semantic changes made during discussion, so
this reply is also the announcement of those RFC changes.
Thanks again for taking the time to review this. The
lifecycle/ownership feedback in particular made the model much
cleaner.
Best regards,
Pratik
On Mon, 28 Sep 2026 15:00:09 +0200, Nicolas Grekas
nicolas.grekas+php@gmail.com wrote:
Hi Pratik,
thanks for the RFC, PHP definitely needs native terminal support, calling stty is a workaround we've been carrying since way too long.
Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus tim@bastelstu.be a écrit :
Hi
I would like to formally propose the Io\Terminal API for PHP 8.7:
Thank you for the RFC. Some questions to start of the discussion:
- Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes.
- It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC.
Terminal::create() should probably be ::fromStdio() or similar.
I'm not sure about false vs Exception for the various methods.
enableRawMode()should probably be Exception, forreadKey()the
falsereturn is not explained. Also the behavior of what happens whena timeout strikes is not explained.
- Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record's own fd. That's order-independent by construction and immune to
fclose(). The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token "restores its saved terminal mode when it is destroyed", which isn't what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.Cheers,
Nicolas
Hi Tim, Nicolas,
One quick follow-up after my earlier mail: I've now finished the
corresponding cleanup in the php-src PR as well.
Tim, I went through the implementation/test review comments and
addressed the remaining points there.
Nicolas, the shared raw-mode record and terminal identity changes are
now in place too, including restoration through the record's own
descriptor and keeping unrelated PTYs separate.
The RFC text and the implementation should now describe the same
lifetime/error semantics:
https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941
I also fixed the Linux PTY EIO test expectation on the current head.
I don't want to keep adding noise to the list, so I'll leave it here
unless I've missed something. Thanks again for the detailed review.
Best,
Pratik
On Mon, 28 Sep 2026 15:00:09 +0200, Nicolas Grekas
nicolas.grekas+php@gmail.com wrote:
Hi Pratik,
thanks for the RFC, PHP definitely needs native terminal support, calling stty is a workaround we've been carrying since way too long.
Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus tim@bastelstu.be a écrit :
Hi
I would like to formally propose the Io\Terminal API for PHP 8.7:
Thank you for the RFC. Some questions to start of the discussion:
- Should TerminalSize have a regular constructor? It seems to be safe
to allow constructing it from userland, e.g. for testing purposes.
- It would help readability if the stub would indicate the
non-serializability (and strict properties) instead of mentioning it in
the prose. Basically you can just take the stub file from your PR and
include it in the RFC.
Terminal::create() should probably be ::fromStdio() or similar.
I'm not sure about false vs Exception for the various methods.
enableRawMode()should probably be Exception, forreadKey()the
falsereturn is not explained. Also the behavior of what happens whena timeout strikes is not explained.
- Should ModeToken have a property that points back to the
corresponding Terminal? Overall the interaction between the destructors
and ModeToken and restoreMode should be explained more. As an example,
what happens if the Terminal object dies before the ModeToken object?
What if I create two Terminal objects for the same terminal?
Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record's own fd. That's order-independent by construction and immune to
fclose(). The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token "restores its saved terminal mode when it is destroyed", which isn't what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.Cheers,
Nicolas
Hi Tim, Nicolas,
One quick follow-up after my earlier mail: I've now finished the
corresponding cleanup in the php-src PR as well.Tim, I went through the implementation/test review comments and
addressed the remaining points there.Nicolas, the shared raw-mode record and terminal identity changes are
now in place too, including restoration through the record's own
descriptor and keeping unrelated PTYs separate.The RFC text and the implementation should now describe the same
lifetime/error semantics:https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941I also fixed the Linux PTY EIO test expectation on the current head.
I don't want to keep adding noise to the list, so I'll leave it here
unless I've missed something. Thanks again for the detailed review.Best,
Pratik
Please remember to bottom-post. :-)
I am in favor of this RFC in general. It addresses the sort of problem that does belong in stdlib.
-
getSize()'s error return is null. Not false. False-on-error is an anti-pattern we should be exterminating with extreme prejudice. Or possibly an exception, but not false.
-
As I'm not familiar with the underlying OS tools... what is raw mode? That seems to be just glossed over. It looks like the only useful API method (readKey() ) requires going into raw mode, so I wonder what its purpose is.
-
That said, raw mode looks like a textbook case for a context manager. :-)
-
Again, readKey() should return null, not false, for all the same reasons.
-
Why does readSecret() not need the same duration/timeout controls as readKey()?
-
I understand all of the usual arguments for making the Terminal class final. However, it also has no interface. That means it's basically impossible to mock for testing purposes. That strikes me as a problem, because any IO boundary should be mockable. I don't know that multiple non-testing implementations makes sense (maybe alternatives to the static constructors?), but we do need some straightforward mechanism to mock a Terminal object. (I assume someone is going to respond with "it's an implementation detail of something else," which is only partially true; I don't want to have to create a pass-through wrapper for something just for testing purposes, and even then, it would be the same API in name only, since it cannot share a type. That hurts interoperability.)
--Larry Garfield
Please remember to bottom-post. :-)
Yep, noted. Thanks.
I am in favor of this RFC in general. It addresses the sort of problem
that does belong in stdlib.
- getSize()'s error return is null. Not false. False-on-error is an
anti-pattern we should be exterminating with extreme prejudice. Or
possibly an exception, but not false.
That makes sense. The original extension used false more broadly, but
for the core API the operational failures are already separate and
throw TerminalException.
I've changed getSize() to return ?TerminalSize, with null meaning that
a usable native size couldn't be obtained.
- As I'm not familiar with the underlying OS tools... what is raw mode?
That seems to be just glossed over. It looks like the only useful API
method (readKey() ) requires going into raw mode, so I wonder what its
purpose is.
Fair point. The RFC was assuming too much terminal background there.
In canonical mode the terminal normally buffers input until a line is
complete, may echo it, and handles some control characters itself. Raw
mode turns off that line-oriented processing so the application can
react to individual key presses and terminal sequences directly.
One thing that also wasn't clear is that callers don't need to call
enableRawMode() before readKey() or readSecret(). Both handle the
temporary mode change internally.
enableRawMode() is there for longer-running interactive code that
wants to keep the terminal raw across multiple reads/redraws.
I've clarified that in the RFC.
- That said, raw mode looks like a textbook case for a context manager. :-)
Conceptually, yes. That's what I was trying to model with ModeToken:
it represents the active lease, and restoreMode() gives an explicit
way to release it in a try/finally.
PHP doesn't have a general language-level context-manager construct,
and I didn't want to add a terminal-specific callback abstraction just
for this, so I kept the primitive explicit.
- Again, readKey() should return null, not false, for all the same reasons.
Changed that as well. readKey() now returns Key|string|null, with null
meaning the timeout expired before a complete input value was
available. Operational failures still throw TerminalException.
- Why does readSecret() not need the same duration/timeout controls as
readKey()?
There wasn't a good reason for the overall timeout to be missing, so
readSecret() now accepts an optional Time\Duration too.
It returns null when that timeout expires, while "" still means the
user actually submitted an empty secret.
I didn't add sequenceTimeout to readSecret(). Unlike readKey(), it
doesn't expose terminal escape sequences to the caller, so that
ambiguity handling can stay internal.
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. [...]
I agree with the testing concern. I've added TerminalInterface while
keeping the native Terminal final, so application and library code can
type against something that can be replaced by a userland fake.
I also added ModeTokenInterface for fake implementations. The native
Terminal still only accepts a native token belonging to the same
logical terminal; foreign, stale, or unrelated tokens are rejected
with ValueError.
I've updated the RFC to match these changes and expanded the
raw-mode/testability sections as well.
Thanks for the review.
Best,
Pratik
- As I'm not familiar with the underlying OS tools... what is raw mode?
That seems to be just glossed over. It looks like the only useful API
method (readKey() ) requires going into raw mode, so I wonder what its
purpose is.Fair point. The RFC was assuming too much terminal background there.
In canonical mode the terminal normally buffers input until a line is
complete, may echo it, and handles some control characters itself. Raw
mode turns off that line-oriented processing so the application can
react to individual key presses and terminal sequences directly.One thing that also wasn't clear is that callers don't need to call
enableRawMode() before readKey() or readSecret(). Both handle the
temporary mode change internally.enableRawMode() is there for longer-running interactive code that
wants to keep the terminal raw across multiple reads/redraws.I've clarified that in the RFC.
Thanks, that does make it clearer! So the reason to use raw mode yourself would be, for instance, for a game where you're capturing the arrow keys and WASD, or something like that? (Concrete examples would help.).
This also makes me think that a readLine() method makes sense in this base tool, not at a higher level. (I haven't done much console-GUI works so I am not familiar with the typical patterns, but it seems like the natural complement to readKey().)
- That said, raw mode looks like a textbook case for a context manager. :-)
Conceptually, yes. That's what I was trying to model with ModeToken:
it represents the active lease, and restoreMode() gives an explicit
way to release it in a try/finally.PHP doesn't have a general language-level context-manager construct,
and I didn't want to add a terminal-specific callback abstraction just
for this, so I kept the primitive explicit.
Yes, that's more of an aside for the audience. Arnaud and I have an RFC out (currently on hold) for context managers, and this would be another very good use case for them.
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. [...]I agree with the testing concern. I've added TerminalInterface while
keeping the native Terminal final, so application and library code can
type against something that can be replaced by a userland fake.I also added ModeTokenInterface for fake implementations. The native
Terminal still only accepts a native token belonging to the same
logical terminal; foreign, stale, or unrelated tokens are rejected
with ValueError.
Conventions for Internals say to not use a *Interface suffix. It's unnecessary. I would suggest either
interface Terminal {
public function readKey();
// ...
}
class SystemTerminal implements Terminal {
public static function fromStdIo(): self {}
public static function fromStreams(): self {}
}
or possibly:
class StdIoTerminal implements Terminal {
// .. No factories.
}
class StreamsTerminal implements Terminal {
public function __construct($in, $out = null) {}
}
For ModeToken, I'm not sure if it makes sense to have separate classes for each core terminal. It's just an opaque value object, really, so I don't know what pattern we'd want here.
--Larry Garfield
Thanks, that does make it clearer! So the reason to use raw mode yourself would be, for instance, for a game where you're capturing the arrow keys and WASD, or something like that? (Concrete examples would help.)
Yep, exactly. Games are one example, but interactive TUIs are probably
the more common case here: menus, search/select UIs, editors, anything
that needs to react to individual key presses and redraw without
waiting for Enter.
This also makes me think that a
readLine()method makes sense in this base tool, not at a higher level.
I did think about that. My hesitation is that normal PHP streams
already handle line-oriented reads pretty well, while this proposal is
mainly trying to cover the terminal-specific bits that aren't exposed
portably today.
So for the first core version I'd rather keep readLine() out instead
of duplicating fgets()-style behavior. The extension can still be
useful as a backport/reference implementation and carry convenience
APIs that don't necessarily need to be in core.
Conventions for Internals say to not use a *Interface suffix. It's unnecessary.
Thanks, I hadn't considered that core naming convention when I added
the interfaces.
I used the *Interface names mainly so I could add the mockable
contract without renaming the existing Terminal and ModeToken classes.
I see the naming issue though. I want to check whether changing the
class/interface split is worth the additional public API churn before
changing it again.
For ModeToken, I'm not sure if it makes sense to have separate classes for each core terminal. It's just an opaque value object, really, so I don't know what pattern we'd want here.
Yeah, I agree. I don't see much value in separate token classes
either. The main thing I need to preserve is validating native tokens
against the logical terminal they belong to, while still keeping
userland implementations mockable.
Best regards,
Pratik
Hi internals, Tim, Nicolas and Larry,
Sorry, I sent this update separately by mistake and left the list off
Cc. Posting it here to keep the discussion in the RFC thread.
I've updated Io\Terminal to version 0.3 and pushed the corresponding
implementation changes:
https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941
Thanks for the feedback. I revisited line input and naming after my
last reply, and wanted to explain the decisions together here.
- Naming and testability
Terminal and ModeToken are now the interfaces, with final
SystemTerminal and SystemModeToken as the native implementations. This
follows the naming convention Larry pointed out and lets libraries use
userland implementations for testing.
I kept one native terminal class with fromStdio() and fromStreams().
Selecting the streams does not need a separate public class when the
operations are otherwise the same. There is also one native token
class.
TerminalSize has a public constructor requiring positive dimensions,
so tests can construct sizes directly. The RFC includes the actual
stub and its strict-properties and non-serializable annotations.
- Why
readLine()is included
My earlier reasoning focused too much on the overlap with fgets().
Having line input on Terminal gives libraries the same interface for
keys, lines and hidden input, including native Windows console
handling and coordination with raw-mode leases.
readLine() removes LF/CRLF, preserves other whitespace, returns null
on immediate EOF, and returns a final unterminated line at EOF. POSIX
uses the existing line discipline; Windows uses native console line
input and restores the previous mode. Redirected PHP streams retain
their buffering, blocking and read-timeout behavior.
It throws TerminalException while a managed raw-mode lease is active
for that terminal, including one acquired through another wrapper.
Silently overriding the mode would interfere with the code holding
that lease.
I kept the timeout parameter out because enforcing a portable
whole-line deadline while preserving native editing would require more
control over the editing loop. History, completion and richer editing
remain userland concerns.
- Raw-mode ownership and token lifetime
The implementation uses the shared record model Nicolas suggested. The
first lease saves the original mode and holds an independent
restoration descriptor or handle. Later leases share that record, and
the last release restores the saved mode, regardless of release order.
Closing the original stream does not by itself prevent restoration.
Wrappers for the same logical terminal coordinate, while unrelated
PTYs remain separate. The token is opaque and can outlive its wrapper.
SystemTerminal retains its latest token, so unsetting only the
caller's variable may leave that lease active.
Destruction and request shutdown attempt cleanup; explicit
restoreMode() reports restoration failures. Native restoration rejects
foreign, consumed or unrelated tokens with ValueError. Without an
argument, restoreMode() returns false when the wrapper has no active
retained token, including one already released through another
wrapper.
- When explicit raw mode is useful
Games, arrow-key menus, search/select UIs and editors need input
without waiting for Enter. An explicit lease keeps the terminal raw
between reads and redraws.
readKey() and readSecret() manage their own temporary mode, so
individual reads do not require enableRawMode() first. Longer sessions
use a lease with try/finally and restoreMode().
- Return values, timeouts and secret input
getSize() returns null when no usable size is available. Key and
secret reads return null on timeout. Operational failures, key-input
EOF and secret cancellation throw TerminalException.
readSecret() accepts an overall Time\Duration timeout. Echo is
disabled during entry; the method does not display the typed
characters. An empty submitted secret returns "", while a timeout
returns null. Escape, Ctrl+C and Ctrl+D cancel with TerminalException.
For timeout arguments, null means no finite deadline, zero makes a
non-blocking attempt, and negative durations throw ValueError.
readSecret() keeps escape-sequence handling internal.
- Partial input and regression coverage
The RFC now distinguishes incomplete UTF-8 bytes retained across
key-read timeouts from incomplete POSIX escape sequences, which may
return a consumed prefix. The POSIX sequence ambiguity window cannot
extend the overall timeout.
Regression tests cover cross-wrapper restoration and subsequent line
reads, token lifetime, unrelated PTYs, and native POSIX line reads
encountering EAGAIN/EWOULDBLOCK. Temporary unavailability now causes
those native line reads to wait for input rather than being treated as
EOF.
I'd like to keep the feature set steady now and give version 0.3 the
full 14-day cooldown. Once that has elapsed and substantive discussion
is settled, I'll post a separate Intent to Vote. Implementation review
and platform testing can continue alongside that.
Best Regards,
Pratik
Hi
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. (I assume someone is going to respond with
"it's an implementation detail of something else," which is only
partially true; I don't want to have to create a pass-through wrapper
for something just for testing purposes, and even then, it would be the
same API in name only, since it cannot share a type. That hurts
interoperability.)
I very strongly disagree with the interface suggestions for the exact
“implementation detail” reason you mentioned and the introduction of the
interface has made the API much worse:
The (System)Terminal class is an implementation detail of something
else and it is only exercised as part of the glue code within an
integration test. It is not the IO boundary, the “TTY stream” you
provide to the Terminal is. You need the TTY stream separately anyway,
because the (System)Terminal does not provide any mechanism to write to
the output (and the underlying stream is not exposed either). In your
tests you would then attach an appropriate stream to the terminal (e.g.
using proc_open() with pty descriptors, which is what the RFC’s own
implementation already uses for testing).
This is very much like the final Random\Randomizer where you would
have unit tests for the “number consumer”, passing hardcoded values. And
then you have an integration test where you provide a class implementing
the Random\Engine interface as the IO boundary. How the glue code
turns a “Random\Engine” into a “random number” that is passed to the
unit-tested “number consumer” is an implementation detail that is not
worth testing.
There are additional indicators that the SystemTerminal class is more
akin to a “helper” class:
-
Large parts of the
Terminal’s API surface don’t need to be instance
methods, because they don’t really care about the object state (beyond
the stream). They could also be bare functions and they were in the
initial pre-RFC implementation. Having them as instance methods makes
the API a little more convenient to use and enables the RAII reset. -
The interface’s contract is “What
SystemTerminaldoes”. As you say
yourself, you are not sure if there can be multiple non-testing
implementations, and I can't think of any either: TheTerminal
interface does not define an “abstract concept” with multiple equally
legal implementations.
This is contrary to, say, Time\Clock where the Clock is the IO
boundary and you can have SystemClock, GpsClock (when a GPS receiver is
attached to your computer), HttpClock (fetching an HTTP service to
obtain the current time, not useful, but theoretically meaningful), ….
-
The interface is humongous and not well-defined: Terminals expose a
broad API surface and this is reflected by theSystemTerminalclass
and the RFC even cut out some of the methods that are there in the
pre-RFC implementation. It is likely that the API surface will grow in
future PHP versions with additional helper functionality. This also
means that the interface needs to grow, which is an obvious breaking
change. Or new non-well-defined interfaces need to be added, which is
bad API design. In fact v0.3 of the RFC added areadLine()method
which (at least on POSIX) is effectively redundant withfgets(), but
allows consistent access to the “input side” of the terminal. It is not
unlikely that a future PHP version might want to addreadUntilEof()or
similar. -
The interface being a direct mapping of the Terminal API surface also
resulted in theModeTokeninterface being added, which is an interface
for something that is an opaque “token” (value) object. And then due to
interface constraints / the lack of generics, theTerminalinterface
requires anyTerminalto accept anyModeTokenin the signature of
restoreMode()just to validate that it is theModeToken
implementation that belongs to theTerminal. The RFC specifies a
ValueErrorhere, but it really is aTypeErrorthat cannot be
expressed by the type system. -
This has also resulted in the naming weirdness: The obvious name is
“Terminal”, the “System” part in “SystemTerminal” carries no additional
information.
Best regards
Tim Düsterhus
Hi
- I understand all of the usual arguments for making the Terminal class
final. However, it also has no interface. That means it's basically
impossible to mock for testing purposes. That strikes me as a problem,
because any IO boundary should be mockable. I don't know that multiple
non-testing implementations makes sense (maybe alternatives to the
static constructors?), but we do need some straightforward mechanism to
mock a Terminal object. (I assume someone is going to respond with
"it's an implementation detail of something else," which is only
partially true; I don't want to have to create a pass-through wrapper
for something just for testing purposes, and even then, it would be the
same API in name only, since it cannot share a type. That hurts
interoperability.)I very strongly disagree with the interface suggestions for the exact
“implementation detail” reason you mentioned and the introduction of the
interface has made the API much worse:The (System)Terminal class is an implementation detail of something
else and it is only exercised as part of the glue code within an
integration test. It is not the IO boundary, the “TTY stream” you
provide to the Terminal is. You need the TTY stream separately anyway,
because the (System)Terminal does not provide any mechanism to write to
the output (and the underlying stream is not exposed either). In your
tests you would then attach an appropriate stream to the terminal (e.g.
usingproc_open()withptydescriptors, which is what the RFC’s own
implementation already uses for testing).This is very much like the final
Random\Randomizerwhere you would
have unit tests for the “number consumer”, passing hardcoded values. And
then you have an integration test where you provide a class implementing
theRandom\Engineinterface as the IO boundary. How the glue code
turns a “Random\Engine” into a “random number” that is passed to the
unit-tested “number consumer” is an implementation detail that is not
worth testing.There are additional indicators that the
SystemTerminalclass is more
akin to a “helper” class:
Large parts of the
Terminal’s API surface don’t need to be instance
methods, because they don’t really care about the object state (beyond
the stream). They could also be bare functions and they were in the
initial pre-RFC implementation. Having them as instance methods makes
the API a little more convenient to use and enables the RAII reset.The interface’s contract is “What
SystemTerminaldoes”. As you say
yourself, you are not sure if there can be multiple non-testing
implementations, and I can't think of any either: TheTerminal
interface does not define an “abstract concept” with multiple equally
legal implementations.This is contrary to, say, Time\Clock where the Clock is the IO
boundary and you can have SystemClock, GpsClock (when a GPS receiver is
attached to your computer), HttpClock (fetching an HTTP service to
obtain the current time, not useful, but theoretically meaningful), ….
The interface is humongous and not well-defined: Terminals expose a
broad API surface and this is reflected by theSystemTerminalclass
and the RFC even cut out some of the methods that are there in the
pre-RFC implementation. It is likely that the API surface will grow in
future PHP versions with additional helper functionality. This also
means that the interface needs to grow, which is an obvious breaking
change. Or new non-well-defined interfaces need to be added, which is
bad API design. In fact v0.3 of the RFC added areadLine()method
which (at least on POSIX) is effectively redundant withfgets(), but
allows consistent access to the “input side” of the terminal. It is not
unlikely that a future PHP version might want to addreadUntilEof()or
similar.The interface being a direct mapping of the Terminal API surface also
resulted in theModeTokeninterface being added, which is an interface
for something that is an opaque “token” (value) object. And then due to
interface constraints / the lack of generics, theTerminalinterface
requires anyTerminalto accept anyModeTokenin the signature of
restoreMode()just to validate that it is theModeToken
implementation that belongs to theTerminal. The RFC specifies a
ValueErrorhere, but it really is aTypeErrorthat cannot be
expressed by the type system.This has also resulted in the naming weirdness: The obvious name is
“Terminal”, the “System” part in “SystemTerminal” carries no additional
information.Best regards
Tim Düsterhus
My main concern is being able to mock the terminal in order to effectively test code that uses the terminal, without putting a proprietary thin wrapper around it (which largely defeats the purpose of having a good API in core). Interfaces are the standard way of doing that. If you have a suggestion for a better way, I'm happy to see it.
--Larry Garfield
Hi
My main concern is being able to mock the terminal in order to
effectively test code that uses the terminal, without putting a
proprietary thin wrapper around it (which largely defeats the purpose
of having a good API in core).
It is not clear to me what you mean by “proprietary” here. As I had
mentioned in my email, the RFC’s own PR is tested against processes
spawned using proc_open() with pty descriptors. The tests are
naturally BSD-licensed, just like PHP itself is (since PHP 8.6).
In your test you would create a process that emits scripted output
(possibly in response to some input), just like you would in the “mocked
interface implementation” and then pass the PTY input/output streams to
whatever you want to test, which will then call
(System)Terminal::fromStreams() using them as parameters. The logic
under test reads inputs from the Terminal instance and writes output
using fwrite(). The subprocess will also enable you to properly model
concurrency, delay and timing in IO processing. Terminal interactions in
the real world are not synchronous either: The terminal logic relies on
timing to distinguish escape sequences from individual characters
(that's what the $sequenceTimeout is for) and the user might already
provide additional input while your application is still busy rendering
output and not yet expecting additional data.
Interfaces are the standard way of doing that. If you have a
suggestion for a better way, I'm happy to see it.
My email included 5 arguments as to why an interface is the wrong design
here. Do you plan to engage with those?
Best regards
Tim Düsterhus
Hi
My main concern is being able to mock the terminal in order to
effectively test code that uses the terminal, without putting a
proprietary thin wrapper around it (which largely defeats the purpose
of having a good API in core).It is not clear to me what you mean by “proprietary” here. As I had
mentioned in my email, the RFC’s own PR is tested against processes
spawned usingproc_open()withptydescriptors. The tests are
naturally BSD-licensed, just like PHP itself is (since PHP 8.6).
I didn't mean proprietary as in license. I mean, for example, a SymfonyTerminal that wraps a Terminal so that SymfonyTerminal can be mocked. Which of course is different than LaravelTerminal. That would be a bad place to end up, and we should avoid that. ("Wrap 3rd party code so you can mock it" is a common recommendation, though as in this case it can lead to other problems.)
In your test you would create a process that emits scripted output
(possibly in response to some input), just like you would in the “mocked
interface implementation” and then pass the PTY input/output streams to
whatever you want to test, which will then call
(System)Terminal::fromStreams()using them as parameters. The logic
under test reads inputs from the Terminal instance and writes output
usingfwrite(). The subprocess will also enable you to properly model
concurrency, delay and timing in IO processing. Terminal interactions in
the real world are not synchronous either: The terminal logic relies on
timing to distinguish escape sequences from individual characters
(that's what the$sequenceTimeoutis for) and the user might already
provide additional input while your application is still busy rendering
output and not yet expecting additional data.Interfaces are the standard way of doing that. If you have a
suggestion for a better way, I'm happy to see it.My email included 5 arguments as to why an interface is the wrong design
here. Do you plan to engage with those?
No, because I am not advocating for interfaces. I am advocating for a clean and obvious mocking/testing mechanism, for which interfaces are a common solution. I am not wedded to interfaces as the solution, just that there is a reliable one that is self-evident (and/or documented). That is, my invitation to suggest a better way was in no way factious or snarky.
If I understand what you and Bob (thanks Bob) are suggesting, one would do something like:
$in = fopen('php://memory');
$out = fopen('php://memory');
fputs($in, "first line\n");
fputs($in, "second line\n");
rewind($in);
$t = Terminal::fromStreams($in, $out);
$line = $t->readLine();
assert($line === 'first line');
$line = $t->readLine();
assert($line === 'second line');
Is that correct? Pratik, are you able to confirm (via tests) that this would work as a testing approach, including for key and secret reads?
If so, then I agree the interfaces become unnecessary, and the above should be included in the RFC as the recommended testing approach.
Although, it occurs to me while typing the above, the Terminal accepts an output stream, but doesn't appear to have any output API. When is the output stream even used? Should it be removed, or a print() (or similar) API added?
--Larry Garfield
Hi
If I understand what you and Bob (thanks Bob) are suggesting, one would
do something like:$in = fopen('php://memory');
$out = fopen('php://memory');fputs($in, "first line\n");
fputs($in, "second line\n");
rewind($in);$t = Terminal::fromStreams($in, $out);
$line = $t->readLine();
assert($line === 'first line');
$line = $t->readLine();
assert($line === 'second line');Is that correct? Pratik, are you able to confirm (via tests) that this
would work as a testing approach, including for key and secret reads?
Except for the missing $mode on fopen(), literally just that would
work. I just compiled the branch and ran:
<?php
$in = fopen('php://memory', 'w+');
$out = fopen('php://memory', 'w+');
fputs($in, "first line\n");
fputs($in, "second line\n");
rewind($in);
$t = Io\Terminal\SystemTerminal::fromStreams($in, $out);
$line = $t->readLine();
var_dump($line);
$line = $t->readLine();
var_dump($line);
the output is:
string(10) "first line"
string(11) "second line"
However for readKey() et al you need a “PTY” handle, so you would, as
I mentioned in both of my replies, spawn sub-processes with stdin /
stdout being of type pty in the descriptor_spec (this incidentally
doesn't seem to be documented yet, except for the user comments) and
appropriate environment variables. Spawning the side using the
Terminal in the sub-process avoids weird inverted logic with regard to
which descriptor is the logical input and which one is the logical
output: Writing to the input handle emulates keyboard input and you can
assert on whatever you get back on the output handle:
<?php
use Io\Terminal\SystemTerminal;
function `app()`: void
{
$terminal = SystemTerminal::fromStdio();
// The mode is kept on the terminal, we only need the token
// for explicit resets.
(void)$terminal->enableRawMode();
// Synchronization point
echo "test ready\n";
$key = $terminal->readKey();
var_dump($key);
// Simulate timing / computation that happens.
sleep(1);
echo "Password?", PHP_EOL;
$secret = $terminal->readSecret();
echo "Got: ", $secret, "\n";
}
if (($argv[1] ?? null) === '--app') {
`app()`;
`exit()`;
}
$proc = proc_open(
[PHP_BINARY, __FILE__, '--app'],
[0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
$pipes,
null,
['TERM' => 'xterm-256color'],
);
// Wait for the synchronization point that confirms the application
is up and running
// and accepting keyboard input. In the real world this happens
through the user
// observing the output (which is slower than the application
start).
fgets($pipes[1]);
// And now press the “Up” arrow.
fwrite($pipes[0], "\e[A");
echo fgets($pipes[1]);
// Write the password in anticipation of the prompt (which is
delayed by computation,
// simulating user behavior).
fwrite($pipes[0], "hunter2\n");
// Assert the prompt and output confirming the password input.
$prompt = trim(fgets($pipes[1]));
assert($prompt === "Password?");
var_dump(trim(fgets($pipes[1])));
proc_close($proc);
The output of that is:
enum(Io\Terminal\Key::Up)
string(12) "Got: hunter2"
Spawning a process is comparatively heavy, but “testing the IO glue
code” is integration test territory, the business logic - e.g. the
actual password verification - is already tested independently as part
of a unit test.
If so, then I agree the interfaces become unnecessary, and the above
should be included in the RFC as the recommended testing approach.Although, it occurs to me while typing the above, the Terminal accepts
an output stream, but doesn't appear to have any output API. When is
the output stream even used? Should it be removed, or a print() (or
similar) API added?
The output stream is used to determine the terminal size - which makes
sense, since you want to know the size of what you're writing to. For
output you can just fwrite() to the handle directly, but having some
output functionality makes sense to me as future scope (e.g. a function
to print some text with ANSI color escapes, which may be what's meant by
“ANSI support” in the future scope).
Best regards
Tim Düsterhus
Hi all,
Larry, Tim, Bob, thanks for the detailed replies. Tim, thanks also for
compiling the branch and checking the POSIX example.
Is that correct? Pratik, are you able to confirm (via tests) that this
would work as a testing approach, including for key and secret reads?
For readLine(), yes: exactly as you wrote it, I can confirm it works. I ran:
$in = fopen('php://memory', 'w+');
fwrite($in, "first line\nsecond line\n");
rewind($in);
$t = Io\Terminal\SystemTerminal::fromStreams($in);
var_dump($t->readLine());
var_dump($t->readLine());
var_dump($t->readLine());
and got:
string(10) "first line"
string(11) "second line"
`NULL`
I also checked empty lines, immediate EOF and a final unterminated line.
Those behave as specified.
For readKey() and readSecret(), ordinary streams like php://memory or
php://temp do not work, and are intentionally rejected because those
operations require an actual terminal device. For example:
$t->readKey();
// Io\Terminal\TerminalException: Failed to read key: input stream is
not a terminal
On POSIX, Tim's proc_open() + PTY example is exactly the right kind of test
for this. It gives the code a real terminal while still letting the test
script the input from pure PHP.
I also checked Windows because I did not want to assume the POSIX PTY
mechanism translated there. It does not directly, since proc_open() on
Windows does not provide the same PTY descriptor. I built the current
branch on Windows 11 ARM64 and exercised the native console path instead.
The PHP side was just:
$t = Io\Terminal\SystemTerminal::fromStdio();
var_dump($t->readKey());
A small harness attached to a real Windows console injected KEY_EVENT
records into CONIN$ with WriteConsoleInputW(). Injecting Up produced:
enum(Io\Terminal\Key::Up)
I also checked Down, Left, Right, Enter, Tab, Backspace, Escape, F1,
ordinary characters, repeat counts and Unicode including a UTF-16 surrogate
pair. readSecret() was exercised through the same native console path for
normal input, empty input, backspace, Unicode, Escape/Ctrl+C cancellation
(which throws TerminalException) and zero-timeout return (which returns
null). readLine() was exercised through the native ReadConsoleW() path as
well. The ARM64 build needed an unrelated local Zend SIMD compiler
workaround, but I did not change Io\Terminal for these tests.
So the direct answer is yes for line input, but with a platform distinction
for interactive keys. Ordinary stream behavior can be scripted with normal
PHP streams. Terminal-specific behavior needs a real terminal. On POSIX
that is conveniently available through a PTY in pure PHP, as Tim showed. On
Windows the native implementation is testable too, but the equivalent test
currently needs a real console/native helper rather than the same PTY
recipe. That means Tim and Bob's point about the stream/terminal being the
underlying I/O boundary holds, while Larry's concern about a
straightforward cross-platform testing seam is still relevant for Windows
code that directly consumes readKey().
Although, it occurs to me while typing the above, the Terminal accepts
an output stream, but doesn't appear to have any output API. When is
the output stream even used? Should it be removed, or a print() (or
similar) API added?
I checked this in the implementation. The output stream is not unused.
getSize() queries the output side for terminal dimensions. readKey() also
checks the output side for size changes so it can return Key::Resize, and
on Windows a WINDOW_BUFFER_SIZE_EVENT causes the output side to be queried
again for the current dimensions. So I don't think the output stream should
be removed. I also don't think we need print() or write() just to justify
it; actual application output can continue to use fwrite()/echo and normal
stream APIs.
On the interface question, Tim's objections to the current
Terminal/ModeToken shape still make sense to me, especially the ModeToken
case where a userland token can satisfy the interface but cannot actually
be restored by the native terminal. At the same time, after checking
Windows I don't think I can honestly say that php://memory or stream
injection alone makes the interface unnecessary everywhere. It clearly
solves readLine(), and the PTY gives us a good POSIX integration seam, but
Windows userland code directly consuming readKey() does not have the same
pure-PHP PTY seam today.
I have not changed the RFC or implementation yet. I would like to settle
this point before making another API change, so that the RFC can go into
the next stage with this part resolved rather than carrying the
disagreement forward. The remaining question for me is what testing seam we
want cross-platform userland code which directly consumes readKey() to
have, without keeping the ModeToken problem Tim pointed out.
Best regards,
Pratik
Hi Pratik,
I have not changed the RFC or implementation yet. I would like to settle
this point before making another API change, so that the RFC can go into
the next stage with this part resolved rather than carrying the
disagreement forward. The remaining question for me is what testing seam we
want cross-platform userland code which directly consumes readKey() to
have, without keeping the ModeToken problem Tim pointed out.
I agree with Tim and Bob: drop both interfaces and rename SystemTerminal
to Terminal. The Windows gap closes if readKey() and readSecret() stop
rejecting non-terminal input. A pipe or php://memory stream already
delivers bytes unprocessed, which is all raw mode does to a tty. So on
a non-terminal stream, readKey() can run the POSIX byte decoder,
enableRawMode() can return a lease that changes nothing, and
readSecret() can read to the newline. Tests then use php://memory on
every OS. Callers that need a real terminal have stream_isatty().
readLine() already accepts redirected input, so this keeps the API
consistent.
Three other things:
-
Specify raw mode. On POSIX the patch clears OPOST and ISIG
(cfmakeraw). Symfony's "-icanon -echo" and Laravel Prompts'
"-icanon -isig -echo" clear neither. With OPOST off, every "\n"
output during a lease staircases on POSIX but not on Windows,
because the console's output mode is never touched. Keep OPOST, or
state the flags in the RFC and make Windows match. -
pcntl_fork(): the child inherits the lease list, and its RSHUTDOWN
restores the shared tty, so a TUI loses raw mode when a worker
exits. Store the pid that acquired the lease and skip restoring in
any other process. -
Drop the retained token and the no-argument restoreMode(). A lease
that stays active after the caller's variable is unset undoes the
RAII guarantee. With the token as the only owner, Nicolas's refcount
design covers everything.
Also, adding a Key case later breaks any match() without a default arm,
so add Insert and Shift+Tab now.
--
Ilia Alshanetsky
Technologist, CTO, Entrepreneur
E: ilia@ilia.ws
T: @iliaa
B: http://ilia.ws
Hi
Also, adding a Key case later breaks any match() without a default arm,
so add Insert and Shift+Tab now.
This is a textbook case of a non-exhaustive enum. I have a very rough
draft RFC in https://wiki.php.net/rfc/non_exhaustive_marker that would
allow for a clear indicator of such enums. I am not currently working on
it (and I'm not sure between interface and attribute). If you (or
someone else) wants to pick that up and build a proper design around it,
please let me know and I'm happy to add you as a coauthor and let you
manage the RFC :-)
Best regards
Tim Düsterhus
Hi
I also checked Windows because I did not want to assume the POSIX PTY
mechanism translated there. It does not directly, sinceproc_open()on
Windows does not provide the same PTY descriptor. I built the current
branch on Windows 11 ARM64 and exercised the native console path
instead.
The PHP side was just:$t = Io\Terminal\SystemTerminal::fromStdio(); var_dump($t->readKey());A small harness attached to a real Windows console injected KEY_EVENT
records into CONIN$ with WriteConsoleInputW(). Injecting Up produced:enum(Io\Terminal\Key::Up)[…]
straightforward cross-platform testing seam is still relevant for
Windows
code that directly consumes readKey().
I know nothing about Windows, but am seeing that proc_open() offers a
create_new_console option that is documented as:
create_new_console (windows only): the new process has a new console,
instead of inheriting its parent's console
Is that perhaps already all that is required? Or would a
(Windows-specific) helper Io\Terminal\inject_fake_keypress() outside
of the main terminal API be helpful?
On the interface question, Tim's objections to the current
Terminal/ModeToken shape still make sense to me, especially the
ModeToken
case where a userland token can satisfy the interface but cannot
actually
be restored by the native terminal. At the same time, after checking
Windows I don't think I can honestly say that php://memory or stream
injection alone makes the interface unnecessary everywhere. It clearly
solvesreadLine(), and the PTY gives us a good POSIX integration seam,
but
Windows userland code directly consuming readKey() does not have the
same
pure-PHP PTY seam today.
It sounds to me that “PHP is unable to spawn terminals on Windows” is an
orthogonal concern and a pre-existing limitation of e.g. the
proc_open() API. The lack of Windows support must not influence the
design of the Io\Terminal API to introduce a “short term” workaround
that will become obsolete as soon as PHP will be able to spawn terminals
on Windows, but that will affect the Io\Terminal API in a negative way
for eternity. We should try very hard that every new API we add to the
standard library is well-designed and convenient to use for 15+ years
without introducing breaking changes. Perhaps Ilia’s suggestion of just
allowing a php://memory stream is okay, keeps readKey() capabilities
in sync with readLine() and fills gap until proc_open() is extended
for proper end-to-end testing?
Best regards
Tim Düsterhus
Hi
- I understand all of the usual arguments for making the Terminal
class final. However, it also has no interface. That means it's
basically impossible to mock for testing purposes. That strikes me
as a problem, because any IO boundary should be mockable. I don't
know that multiple non-testing implementations makes sense (maybe
alternatives to the static constructors?), but we do need some
straightforward mechanism to mock a Terminal object. (I assume
someone is going to respond with "it's an implementation detail of
something else," which is only partially true; I don't want to have
to create a pass-through wrapper for something just for testing
purposes, and even then, it would be the same API in name only, since
it cannot share a type. That hurts interoperability.)I very strongly disagree with the interface suggestions for the exact
“implementation detail” reason you mentioned and the introduction of
the interface has made the API much worse:The (System)Terminal class is an implementation detail of something
else and it is only exercised as part of the glue code within an
integration test. It is not the IO boundary, the “TTY stream” you
provide to the Terminal is. You need the TTY stream separately anyway,
because the (System)Terminal does not provide any mechanism to write
to the output (and the underlying stream is not exposed either). In
your tests you would then attach an appropriate stream to the terminal
(e.g. usingproc_open()withptydescriptors, which is what the
RFC’s own implementation already uses for testing).This is very much like the final
Random\Randomizerwhere you would
have unit tests for the “number consumer”, passing hardcoded values.
And then you have an integration test where you provide a class
implementing theRandom\Engineinterface as the IO boundary. How the
glue code turns a “Random\Engine” into a “random number” that is
passed to the unit-tested “number consumer” is an implementation
detail that is not worth testing.There are additional indicators that the
SystemTerminalclass is
more akin to a “helper” class:
Large parts of the
Terminal’s API surface don’t need to be
instance methods, because they don’t really care about the object
state (beyond the stream). They could also be bare functions and they
were in the initial pre-RFC implementation. Having them as instance
methods makes the API a little more convenient to use and enables the
RAII reset.The interface’s contract is “What
SystemTerminaldoes”. As you
say yourself, you are not sure if there can be multiple non-testing
implementations, and I can't think of any either: TheTerminal
interface does not define an “abstract concept” with multiple equally
legal implementations.This is contrary to, say, Time\Clock where the Clock is the IO
boundary and you can have SystemClock, GpsClock (when a GPS receiver
is attached to your computer), HttpClock (fetching an HTTP service to
obtain the current time, not useful, but theoretically meaningful), ….
The interface is humongous and not well-defined: Terminals expose a
broad API surface and this is reflected by theSystemTerminalclass
and the RFC even cut out some of the methods that are there in the
pre-RFC implementation. It is likely that the API surface will grow in
future PHP versions with additional helper functionality. This also
means that the interface needs to grow, which is an obvious breaking
change. Or new non-well-defined interfaces need to be added, which is
bad API design. In fact v0.3 of the RFC added areadLine()method
which (at least on POSIX) is effectively redundant withfgets(), but
allows consistent access to the “input side” of the terminal. It is
not unlikely that a future PHP version might want to add
readUntilEof()or similar.The interface being a direct mapping of the Terminal API surface
also resulted in theModeTokeninterface being added, which is an
interface for something that is an opaque “token” (value) object. And
then due to interface constraints / the lack of generics, the
Terminalinterface requires anyTerminalto accept anyModeToken
in the signature ofrestoreMode()just to validate that it is the
ModeTokenimplementation that belongs to theTerminal. The RFC
specifies aValueErrorhere, but it really is aTypeErrorthat
cannot be expressed by the type system.This has also resulted in the naming weirdness: The obvious name is
“Terminal”, the “System” part in “SystemTerminal” carries no
additional information.Best regards
Tim Düsterhus
Well laid out Tim,
the I/O boundary comes from the streams passed to fromStreams().
For any mocking purposes / different terminals, it's a matter of
composing the stream. We just need to make sure that it's actually
possible to construct such a tty in tests for example, which can
intercept the events / set state for size etc..
Terminal (yes, SystemTerminal shouldn't be a thing) is just a class
which takes stuff from the stream and outputs it. There's no IO
operations in itself (from userland perspective), only on the resource,
which is the actual underlying entity forwarding IO operations to the
kernel.
Thanks,
Bob