Add new PTY node (replaces Pseudoterminal node)

Uses fork of node-pty native code for forking pseudoterminals.
Uses libuv pipe handle to communicate with the child process.

- Paves the way for cross-platform (Linux, macOS and Windows) support.
- Renames Pseudoterminal to PTY (which is much easier to type and spell :D).
- Better performance than the old Pseudoterminal node. Especially when
  streaming large amounts of data such as running the `yes` command.
- Allows setting custom file, args, initial window size, cwd, env vars
  (including important ones such as TERM and COLORTERM) and uid/gid
  on Linux and macOS.
- Returns process exit code and terminating signal.
This commit is contained in:
Leroy Hopson 2021-07-03 00:27:34 +07:00 committed by Leroy Hopson
parent bfa561357e
commit 0dd2378387
36 changed files with 1268 additions and 442 deletions

View file

@ -1,51 +0,0 @@
# Pseudoterminal
**Inherits:** [Node] < [Object]
Can be used with the [Terminal] node to get an actual shell. Currently only tested/working on Linux and MacOS.
## Methods
| Returns | Signature |
|---------|----------------------------------------|
| void | write **(** [PoolByteArray] data **)** |
| void | resize **(** [Vector2] size **)** |
## Signals
- **data_sent** **(** [PoolByteArray] data **)**
Emitted when some data comes out of the pseudoterminal.
In a typical setup this signal would be connected to the [Terminal]'s `write()` method.
---
- **exited** **(** [int] status **)**
Emitted when the pseudoterminal's process (typically a shell like `bash` or `sh`) has exited. `status` is the exit code.
For example, if you are using the terminal with a `bash` shell, then issuing the `exit` command would cause this signal to be emitted.
```bash
> exit
exit
```
## Method Descriptions
- void **write** **(** [PoolByteArray] data **)**
Writes data to the pseudoterminal. In a typical setup this would be connected to the [Terminal]'s `data_sent()` signal.
- void **resize** **(** [Vector2] size **)**
Used to notify the pseudoterminal about window size changes. In a typical setup it would be connected to the [Terminal]'s `size_changed()` signal.
[Node]: https://docs.godotengine.org/en/stable/classes/class_node.html
[int]: https://docs.godotengine.org/en/stable/classes/class_int.html
[Object]: https://docs.godotengine.org/en/stable/classes/class_object.html
[PoolByteArray]: https://docs.godotengine.org/en/stable/classes/class_poolbytearray.html
[Terminal]: ../terminal/README.md
[Vector2]: https://docs.godotengine.org/en/stable/classes/class_vector2.html

View file

@ -0,0 +1,29 @@
# Copyright (c) 2021, Leroy Hopson (MIT License)
tool
extends Object
# Wrapper around libuv utility functions.
# GDNative does not currently support registering static functions so we fake it.
# Only the static functions of this class should be called.
const LibuvUtils := preload("./libuv_utils.gdns")
static func get_os_environ() -> Dictionary:
# While Godot has OS.get_environment(), I could see a way to get all environent
# variables, other than by OS.execute() which would require to much platform
# specific code. Easier to use libuv's utility function.
return LibuvUtils.new().get_os_environ()
static func get_cwd() -> String:
# Use uv_cwd() rather than Directory.get_current_dir() because the latter
# defaults to res:// even if starting godot from a different directory.
return LibuvUtils.new().get_cwd()
static func get_windows_build_number() -> int:
assert(OS.get_name() == "Windows", "This function is only supported on Windows.")
var release: String = LibuvUtils.new().get_os_release()
assert(false, "Not implemented.")
return 0
static func new():
assert(false, "Abstract sealed (i.e. static) class should not be instantiated.")

View file

@ -4,5 +4,5 @@
[resource]
resource_name = "Terminal"
class_name = "Pseudoterminal"
class_name = "LibuvUtils"
library = ExtResource( 1 )

View file

@ -0,0 +1,8 @@
[gd_resource type="NativeScript" load_steps=2 format=2]
[ext_resource path="res://addons/godot_xterm/native/godotxtermnative.gdnlib" type="GDNativeLibrary" id=1]
[resource]
resource_name = "Terminal"
class_name = "Pipe"
library = ExtResource( 1 )

View file

@ -0,0 +1,261 @@
# Derived from https://github.com/microsoft/node-pty/blob/main/src/terminal.ts
# Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
# Copyright (c) 2016, Daniel Imms (MIT License).
# Copyright (c) 2018, Microsoft Corporation (MIT License).
# Copyright (c) 2021, Leroy Hopson (MIT License).
tool
extends Node
const LibuvUtils := preload("./libuv_utils.gd")
const Pipe := preload("./pipe.gdns")
const Terminal := preload("../terminal/terminal.gd")
const DEFAULT_NAME := "xterm-256color"
const DEFAULT_COLS := 80
const DEFAULT_ROWS := 24
const DEFAULT_ENV := {TERM = DEFAULT_NAME, COLORTERM = "truecolor"}
## Default messages to indicate PAUSE/RESUME for automatic flow control.
## To avoid conflicts with rebound XON/XOFF control codes (such as on-my-zsh),
## the sequences can be customized in IPtyForkOptions.
#const FLOW_CONTROL_PAUSE = char(0x13) # defaults to XOFF
#const FLOW_CONTROL_RESUME = char(0x11) # defaults to XON
enum Status { NONE, OPEN, EXITED, ERROR }
const STATUS_NONE = Status.NONE
const STATUS_OPEN = Status.OPEN
const STATUS_EXITED = Status.EXITED
const STATUS_ERROR = Status.ERROR
# Any signal_number can be sent to the pty's process using the kill() function,
# these are just the signals with numbers specified in the POSIX standard.
enum Signal {
SIGHUP = 1, # Hangup
SIGINT = 2, # Terminal interrupt signal
SIGQUIT = 3, # Terminal quit signal
SIGILL = 4, # Illegal instruction
SIGTRAP = 5, # Trace/breakpoint trap
SIGABRT = 6, # Process abort signal
SIGFPE = 8, # Erroneous arithmetic operation
SIGKILL = 9, # Kill (cannot be caught or ignored)
SIGSEGV = 11, # Invalid memory reference
SIGPIPE = 13, # Write on a pipe with no one to read it
SIGALRM = 14, # Alarm clock
SIGTERM = 15, # Termination signal
}
signal data_received(data)
signal exited(exit_code, signum)
signal errored(message)
export (NodePath) var terminal_path := NodePath() setget set_terminal_path
var status := STATUS_NONE
var error_string := ""
var terminal: Terminal = null setget set_terminal
## Name of the terminal to be set in environment ($TERM variable).
#export (String) var term_name: String
# The name of the process.
var process: String
# The process ID.
var pid: int
# The column size in characters.
export (int) var cols: int = DEFAULT_COLS setget set_cols
# The row size in characters.
export (int) var rows: int = DEFAULT_ROWS setget set_rows
# Working directory to be set for the child program.
#export (String) var cwd := LibuvUtils.get_cwd()
# Environment to be set for the child program.
export (Dictionary) var env := DEFAULT_ENV
# If true the environment variables in the env Dictionary will be merged with
# the environment variables of the operating system (e.g. printenv), with the
# former taking precedence in the case of conflicts.
export (bool) var use_os_env := true
# If true, pty will call fork() in in _ready().
export (bool) var autostart := false
# (EXPERIMENTAL)
# If true, PTY node will create a blocking libuv loop in a new thread.
# signals will be emitted using call_deferred.
#export (bool) var use_threads := false
## String encoding of the underlying pty.
## If set, incoming data will be decoded to strings and outgoing strings to bytes applying this encoding.
## If unset, incoming data will be delivered as raw bytes (PoolByteArray type).
## By default 'utf8' is assumed, to unset it explicitly set it to `null`.
#var encoding: String = "utf8"
## (EXPERIMENTAL)
## Whether to enable flow control handling (false by default). If enabled a message of `flow_control_pause`
## will pause the socket and thus blocking the child program execution due to buffer back pressure.
## A message of `flow_control_resume` will resume the socket into flow mode.
## For performance reasons only a single message as a whole will match (no message part matching).
## If flow control is enabled the `flow_control_pause` and `flow_control_resume` messages are not forwarded to
## the underlying pseudoterminal.
#var handle_flow_control: bool = false
#
## (EXPERIMENTAL)
## The string that should pause the pty when `handle_flow_control` is true. Default is XOFF ("\u0013").
#var flow_control_pause: String = FLOW_CONTROL_PAUSE
#
## (EXPERIMENTAL)
## The string that should resume the pty when `handle_flow_control` is true. Default is XON ("\u0011").
#var flow_control_resume: String = FLOW_CONTROL_RESUME
#var _native_term # Platform appropriate instance of this class.
var _pipe: Pipe
func set_cols(value: int):
resize(value, rows)
func set_rows(value: int):
resize(cols, value)
func set_terminal_path(value := NodePath()):
terminal_path = value
set_terminal(get_node_or_null(terminal_path))
func set_terminal(value: Terminal):
if terminal == value:
return
# Disconect the current terminal, if any.
if terminal:
disconnect("data_received", terminal, "write")
terminal.disconnect("data_sent", self, "write")
terminal.disconnect("size_changed", self, "resize")
terminal = value
if not terminal:
return
# Connect the new terminal.
# FIXME! resize(terminal.get_cols(), terminal.get_rows())
if not terminal.is_connected("size_changed", self, "resize"):
terminal.connect("size_changed", self, "resize")
if not terminal.is_connected("data_sent", self, "write"):
terminal.connect("data_sent", self, "write")
if not is_connected("data_received", terminal, "write"):
connect("data_received", terminal, "write")
# Writes data to the socket.
# data: The data to write.
func write(data) -> void:
assert(data is String or data is PoolByteArray)
if data is PoolByteArray:
data = data.get_string_from_utf8()
# if handle_flow_control:
# # PAUSE/RESUME messages are not forwarded to the pty.
# if data == flow_control_pause:
# pause()
# return
# if data == flow_control_resume:
# resume()
# return
# # Everything else goes to the real pty.
_write(data)
func _write(data: String) -> void:
if _pipe:
_pipe.write(data)
# Resizes the dimensions of the pty.
# cols: The number of columns.
# rows: The number of rows.
# Also accepts a single Vector2 argument where x is the the number of columns
# and y is the number of rows.
func resize(cols, rows = null) -> void:
assert(
(cols is Vector2 and rows == null) or (cols is int and rows is int),
"Usage: resize(size: Vector2) or resize(cols: int, rows: int)"
)
if cols is Vector2:
rows = cols.y # Must get rows before reassigning cols!
cols = cols.x
if cols <= 0 or rows <= 0 or cols == NAN or rows == NAN or cols == INF or rows == INF:
push_error("Resizing must be done using positive cols and rows.")
_resize(cols, rows)
func _resize(cols: int, rows: int) -> void:
assert(false, "Not implemented.")
# Close, kill and destroy the pipe.
func destroy() -> void:
pass
# Kill the pty.
# sigint: The signal to send. By default this is SIGHUP.
# This is not supported on Windows.
func kill(sigint: int = Signal.SIGHUP) -> void:
pass
func fork(
p_file: String,
p_args = PoolStringArray(),
p_cwd: String = LibuvUtils.get_cwd(),
p_cols: int = DEFAULT_COLS,
p_rows: int = DEFAULT_ROWS
):
push_error("Not implemented.")
func open(cols: int = DEFAULT_COLS, rows: int = DEFAULT_ROWS) -> void:
pass
func _parse_env(env: Dictionary = {}) -> PoolStringArray:
var keys := env.keys()
var pairs := PoolStringArray()
for key in keys:
var value = env[key]
var valid = key is String and value is String
assert(valid, "Env key/value pairs must be of type String/String.")
if not valid:
push_warning("Skipping invalid env key/value pair.")
continue
pairs.append("%s=%s" % [key, value])
return pairs
func _process(_delta):
if _pipe:
_pipe.poll()
func _notification(what: int):
match what:
NOTIFICATION_PARENTED:
var parent = get_parent()
if parent is Terminal:
set_terminal_path(get_path_to(parent))

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

@ -2,15 +2,15 @@
importer="texture"
type="StreamTexture"
path="res://.import/pseudoterminal_icon.svg-0b26aed87c28626d61aa92bd9e34d5a9.stex"
path="res://.import/pty_icon.svg-e9e42570b4744b3370a02d174395c793.stex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot_xterm/nodes/pseudoterminal/pseudoterminal_icon.svg"
dest_files=[ "res://.import/pseudoterminal_icon.svg-0b26aed87c28626d61aa92bd9e34d5a9.stex" ]
source_file="res://addons/godot_xterm/nodes/pty/pty_icon.svg"
dest_files=[ "res://.import/pty_icon.svg-e9e42570b4744b3370a02d174395c793.stex" ]
[params]

View file

@ -0,0 +1,124 @@
# Derived from https://github.com/microsoft/node-pty/blob/main/src/unixTerminal.ts
# Copyright (c) 2012-2015, Christopher Jeffrey (MIT License).
# Copyright (c) 2016, Daniel Imms (MIT License).
# Copyright (c) 2018, Microsoft Corporation (MIT License).
# Copyright (c) 2021, Leroy Hopson (MIT License).
tool
extends "../pty.gd"
const PTYUnix = preload("./pty_unix.gdns")
const FALLBACK_FILE = "sh"
# Security warning: use this option with great caution, as opened file descriptors
# with higher privileges might leak to the child program.
var uid: int
var gid: int
var thread: Thread
var _fd: int = -1
var _exit_cb: FuncRef
static func get_uid() -> int:
return -1 # Not implemented.
static func get_gid() -> int:
return -1 # Not implemented.
func _ready():
if autostart:
fork()
func _resize(cols: int, rows: int) -> void:
if _fd < 0:
return
PTYUnix.new().resize(_fd, cols, rows)
func _fork_thread(args):
var result = preload("./pty_unix.gdns").new().callv("fork", args)
print(result)
return result
func fork(
file: String = OS.get_environment("SHELL"),
args: PoolStringArray = PoolStringArray(),
cwd = LibuvUtils.get_cwd(),
p_cols: int = DEFAULT_COLS,
p_rows: int = DEFAULT_ROWS,
uid: int = -1,
gid: int = -1,
utf8 = true
):
# File.
if file.empty():
file = FALLBACK_FILE
# Environment variables.
# If we are using OS env vars, sanitize them to remove variables that might confuse our terminal.
var final_env := _sanitize_env(LibuvUtils.get_os_environ()) if use_os_env else {}
for key in env.keys():
final_env[key] = env[key]
var parsed_env: PoolStringArray = _parse_env(final_env)
# Exit callback.
_exit_cb = FuncRef.new()
_exit_cb.set_instance(self)
_exit_cb.function = "_on_exit"
# Actual fork.
var result = PTYUnix.new().fork( # VERY IMPORTANT: The must be set null or 0, otherwise will get an ENOTSOCK error after connecting our pipe to the fd.
file, null, args, parsed_env, cwd, cols, rows, uid, gid, utf8, _exit_cb
)
if result[0] != OK:
push_error("Fork failed.")
status = STATUS_ERROR
return FAILED
_fd = result[1].fd
if _fd < 0:
push_error("File descriptor must be a non-negative integer value.")
status = STATUS_ERROR
return FAILED
_pipe = Pipe.new()
_pipe.open(_fd)
# Must connect to signal AFTER opening, otherwise we will get error ENOTSOCK.
_pipe.connect("data_received", self, "_on_pipe_data_received")
return OK
func _on_pipe_data_received(data):
emit_signal("data_received", data)
func _on_exit(exit_code: int, signum: int) -> void:
emit_signal("exited", exit_code, signum)
func _sanitize_env(env: Dictionary) -> Dictionary:
# Make sure we didn't start our server from inside tmux.
env.erase("TMUX")
env.erase("TMUX_PANE")
# Make sure we didn't start our server from inside screen.
# http://web.mit.edu/gnu/doc/html/screen_20.html
env.erase("STY")
env.erase("WINDOW")
# Delete some variables that might confuse our terminal.
env.erase("WINDOWID")
env.erase("TERMCAP")
env.erase("COLUMNS")
env.erase("LINES")
return env

View file

@ -0,0 +1,7 @@
[gd_resource type="NativeScript" load_steps=2 format=2]
[ext_resource path="res://addons/godot_xterm/native/godotxtermnative.gdnlib" type="GDNativeLibrary" id=1]
[resource]
class_name = "PTYUnix"
library = ExtResource( 1 )