Skip to content

Add Io\Terminal native terminal primitives - #23941

Open
prateekbhujel wants to merge 38 commits into
php:masterfrom
prateekbhujel:rfc-io-terminal
Open

prateekbhujel wants to merge 38 commits into
php:masterfrom
prateekbhujel:rfc-io-terminal

Conversation

@prateekbhujel

@prateekbhujel prateekbhujel commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Implementation of the proposed PHP 8.7 Io\Terminal RFC, with the API proposed for version 0.3:

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

This adds native terminal primitives to ext/standard, alongside Io\Poll, using POSIX terminal APIs and the Windows Console API.

The API provides:

  • Terminal and ModeToken interfaces for userland implementations and test doubles.
  • One native SystemTerminal implementation, created through fromStdio() or fromStreams($input, $output = null).
  • TerminalSize, a directly constructible readonly value object, and getSize(): ?TerminalSize, without COLUMNS/LINES fallbacks.
  • Shared raw-mode leases through enableRawMode(): SystemModeToken and restoreMode(?ModeToken $mode = null): bool.
  • readKey(?Time\Duration $timeout = null, ?Time\Duration $sequenceTimeout = null): Key|string|null.
  • readLine(): ?string.
  • readSecret(?Time\Duration $timeout = null): ?string.
  • TerminalException for 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() returns false when the wrapper has no active retained token.

readKey() and readSecret() 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, returns null on immediate EOF, and returns a final unterminated line at EOF. POSIX terminal reads use the existing line discipline and wait for readability after EAGAIN/EWOULDBLOCK without 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 null on timeout; key-input EOF and secret cancellation throw TerminalException. 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 becomes Key::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; native ReadConsoleW() 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

@prateekbhujel prateekbhujel changed the title Rfc io terminal Add Io\Terminal native terminal primitives Sep 27, 2026
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c
Comment thread ext/standard/tests/terminal/terminal_read_secret.phpt Outdated
Comment on lines +22 to +24
// Key identity and comparison
var_dump(Key::Up === Key::Up);
var_dump(Key::Up !== Key::Down);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just testing that enums work. Overall this test doesn't seem to be particularly useful.

Comment thread ext/standard/tests/terminal/terminal_read_key.phpt Outdated
Comment thread ext/standard/io_terminal.stub.php
Comment thread ext/standard/io_poll.c Outdated
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would should be moved out of io_poll.c as a separate PR.

Comment thread ext/standard/tests/terminal/terminal_read_key.phpt Outdated
Comment thread ext/standard/tests/terminal/terminal_create.phpt Outdated
Comment thread ext/standard/io_terminal.c Outdated
Comment on lines +760 to +767
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduce the scope of variables as much as possible.

Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_poll.h Outdated

// Zero duration poll
$res = $t->readKey(Duration::fromSeconds(0));
var_dump($res === false || is_string($res) || is_object($res));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var_dump($t1 instanceof Terminal);
var_dump($t1);

Comment thread ext/standard/io_terminal.c Outdated
Comment thread ext/standard/io_terminal.c Outdated
- 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.
Comment thread ext/standard/io_terminal.c Outdated
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), and stat() 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())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TimWolla left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ext/standard/io_terminal.c Outdated
Comment on lines +1830 to +1835
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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider object_init_with_constructor() instead of a custom helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the internal initialization helper; Terminal::getSize() now instantiates TerminalSize via object_init_with_constructor().

Comment thread ext/standard/io_terminal.c Outdated
Comment on lines +1640 to +1646
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowing the scope is possible for a number of variables here (probably also in other functions).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed variable declarations to their point of first use throughout io_terminal.c.

Comment thread ext/standard/io_terminal.c Outdated
Comment on lines +2160 to +2167
#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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ext/standard/io_terminal.c Outdated
Comment on lines +2299 to +2304
#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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DanielEScherzer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some initial thoughts

Comment thread ext/standard/io_terminal.c Outdated
# define PATH_MAX 1024
#endif

static bool php_io_terminal_get_slave_dev_from_pty_master(int fd, dev_t *slave_dev)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use more inclusive names in new code, i.e. avoid "slave" and "master"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the new PTY identifiers to neutral terminology; the platform ptsname*() APIs remain unchanged.

Comment thread ext/standard/io_terminal.c Outdated
if (GetConsoleMode(shared->restore_stream, &actual_mode) && actual_mode != shared->saved_mode) {
return false;
}
return true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both blocks end with return true, maybe move that out of the preprocessor conditional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved the common return true outside the conditional.

Comment thread ext/standard/io_terminal.c Outdated

if (intern->valid && intern->shared != NULL) {
const char *err = NULL;
if (!php_io_terminal_release_token_lease(intern, &err)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this error isn't used for anything, so can just pass NULL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated — passing NULL directly since the error output isn't used here.

Comment thread ext/standard/io_terminal.c Outdated
WCHAR *pending_key_high_surrogate
)
{
DWORD records_read;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed for a while, suggest declaring lower down

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated — the declaration now sits at first use.

Comment thread ext/standard/io_terminal.c Outdated
HANDLE handle = input;
DWORD mode = 0;
WCHAR high_surrogate = *pending_high_surrogate;
DWORD raw_mode;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed raw_mode, key, and repeats to their points of initialization.

Comment thread ext/standard/io_terminal.c Outdated
}
mode_changed = raw_mode != mode;

for (;;) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably clearer to use

Suggested change
for (;;) {
while (true) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to while (true).

Comment thread ext/standard/io_terminal.c Outdated

pending->bytes[pending->length++] = key;
if ((key & 0xc0) != 0x80) {
zend_string *invalid = zend_string_init((const char *) pending->bytes, pending->length, false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is just the same result as a break here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this reaches the same common cleanup/return path. Simplified it to break.

Comment thread ext/standard/io_terminal.c Outdated
#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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can just have a semicolon declaration and then don't need to indent everything

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the stub to the semicolon namespace form and regenerated arginfo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@NattyNarwhal

Copy link
Copy Markdown
Member

FWIW the timespec functions may be useful to extract into another utility file in the future. poll has php_poll_timespec_to_ms, if we use them in more places to deal with APIs like these.

Also, perhaps it might be useful to split the Unix/Windows backends into their own files, if it gets unwieldy.

@prateekbhujel

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants