markqvist___Reticulum/RNS/Utilities/rnsh/process.py
2026-04-27 00:12:27 +02:00

785 lines
28 KiB
Python

# Based on the original rnsh program by Aaron Heise (@acehoss)
# https://github.com/acehoss/rnsh - MIT License - Copyright (c) 2023 Aaron Heise
# This version of rnsh is included in RNS under the Reticulum License
#
# Reticulum License
#
# Copyright (c) 2016-2026 Mark Qvist
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# - The Software shall not be used in any kind of system which includes amongst
# its functions the ability to purposefully do harm to human beings.
#
# - The Software shall not be used, directly or indirectly, in the creation of
# an artificial intelligence, machine learning or language model training
# dataset, including but not limited to any use that contributes to the
# training or development of such a model or algorithm.
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import annotations
import asyncio
import contextlib
import copy
import errno
import fcntl
import functools
import os
import pty
import select
import signal
import struct
import sys
import termios
import threading
import tty
import types
import typing
import RNS
import RNS.Utilities.rnsh.exception as exception
CTRL_C = "\x03".encode("utf-8")
CTRL_D = "\x04".encode("utf-8")
def tty_add_reader_callback(fd: int, callback: callable, loop: asyncio.AbstractEventLoop = None):
"""
Add an async reader callback for a tty file descriptor.
Example usage:
def reader():
data = tty_read(fd)
# do something with data
tty_add_reader_callback(self._child_fd, reader, self._loop)
:param fd: file descriptor
:param callback: callback function
:param loop: asyncio event loop to which the reader should be added. If None, use the currently-running loop.
"""
if loop is None:
loop = asyncio.get_running_loop()
loop.add_reader(fd, callback)
def tty_read(fd: int) -> bytes:
"""
Read available bytes from a tty file descriptor. When used in a callback added to a file descriptor using
tty_add_reader_callback(...), this function creates a solution for non-blocking reads from ttys.
:param fd: tty file descriptor
:return: bytes read
"""
if fd_is_closed(fd):
raise EOFError
try:
run = True
result = bytearray()
while not fd_is_closed(fd):
ready, _, _ = select.select([fd], [], [], 0)
if len(ready) == 0:
break
for f in ready:
try:
data = os.read(f, 4096)
except OSError as e:
if e.errno != errno.EIO and e.errno != errno.EWOULDBLOCK:
raise
else:
if not data: # EOF
if data is not None and len(data) > 0:
result.extend(data)
return result
elif len(result) > 0:
return result
else:
raise EOFError
if data is not None and len(data) > 0:
result.extend(data)
return result
except EOFError: raise
except Exception as e: RNS.log(f"TTY read error: {e}", RNS.LOG_ERROR)
def tty_read_poll(fd: int) -> bytes:
"""
Read available bytes from a tty file descriptor. When used in a callback added to a file descriptor using
tty_add_reader_callback(...), this function creates a solution for non-blocking reads from ttys.
:param fd: tty file descriptor
:return: bytes read
"""
if fd_is_closed(fd):
raise EOFError
result = bytearray()
try:
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
while True:
try:
data = os.read(fd, 4096)
if not data:
# EOF
if len(result) > 0:
return result
raise EOFError
result.extend(data)
# continue loop to drain
except OSError as e:
if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN):
break
if e.errno == errno.EIO:
if len(result) > 0:
return result
raise EOFError
raise
except EOFError: raise
except Exception as e: RNS.log(f"TTY read error: {e}", RNS.LOG_ERROR)
return result
def fd_is_closed(fd: int) -> bool:
"""
Check if file descriptor is closed
:param fd: file descriptor
:return: True if file descriptor is closed
"""
try:
fcntl.fcntl(fd, fcntl.F_GETFL) < 0
except OSError as ose:
return ose.errno == errno.EBADF
def tty_unset_reader_callbacks(fd: int, loop: asyncio.AbstractEventLoop = None):
"""
Remove async reader callbacks for file descriptor.
:param fd: file descriptor
:param loop: asyncio event loop from which to remove callbacks
"""
with exception.permit(SystemExit):
if loop is None:
loop = asyncio.get_running_loop()
loop.remove_reader(fd)
def tty_get_winsize(fd: int) -> [int, int, int, int]:
"""
Ge the window size of a tty.
:param fd: file descriptor of tty
:return: (rows, cols, h_pixels, v_pixels)
"""
packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))
rows, cols, h_pixels, v_pixels = struct.unpack('HHHH', packed)
return rows, cols, h_pixels, v_pixels
def tty_set_winsize(fd: int, rows: int, cols: int, h_pixels: int, v_pixels: int):
"""
Set the window size on a tty.
:param fd: file descriptor of tty
:param rows: number of visible rows
:param cols: number of visible columns
:param h_pixels: number of visible horizontal pixels
:param v_pixels: number of visible vertical pixels
"""
if fd < 0:
return
packed = struct.pack('HHHH', rows, cols, h_pixels, v_pixels)
fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)
def process_exists(pid) -> bool:
"""
Check For the existence of a unix pid.
:param pid: process id to check
:return: True if process exists
"""
try:
os.kill(pid, 0)
except OSError:
return False
else:
return True
class TTYRestorer(contextlib.AbstractContextManager):
# Indexes of flags within the attrs array
ATTR_IDX_IFLAG = 0
ATTR_IDX_OFLAG = 1
ATTR_IDX_CFLAG = 2
ATTR_IDX_LFLAG = 4
ATTR_IDX_CC = 5
def __init__(self, fd: int, suppress_logs=False):
"""
Saves termios attributes for a tty for later restoration.
The attributes are an array of values with the following meanings.
tcflag_t c_iflag; /* input modes */
tcflag_t c_oflag; /* output modes */
tcflag_t c_cflag; /* control modes */
tcflag_t c_lflag; /* local modes */
cc_t c_cc[NCCS]; /* special characters */
:param fd: file descriptor of tty
"""
self._fd = fd
self._tattr = None
self._suppress_logs = suppress_logs
self._tattr = self.current_attr()
if not self._tattr and not self._suppress_logs: RNS.log(f"Could not get attrs for fd {fd}", RNS.LOG_DEBUG)
def raw(self):
"""
Set raw mode on tty
"""
if self._fd is None:
return
with contextlib.suppress(termios.error):
tty.setraw(self._fd, termios.TCSANOW)
def original_attr(self) -> [any]:
return copy.deepcopy(self._tattr)
def current_attr(self) -> [any]:
"""
Get the current termios attributes for the wrapped fd.
:return: attribute array
"""
if self._fd is None:
return None
with contextlib.suppress(termios.error):
return copy.deepcopy(termios.tcgetattr(self._fd))
return None
def set_attr(self, attr: [any], when: int = termios.TCSADRAIN):
"""
Set termios attributes
:param attr: attribute list to set
:param when: when attributes should be applied (termios.TCSANOW, termios.TCSADRAIN, termios.TCSAFLUSH)
"""
if not attr or self._fd is None:
return
with contextlib.suppress(termios.error):
termios.tcsetattr(self._fd, when, attr)
def isatty(self):
return os.isatty(self._fd) if self._fd is not None else None
def restore(self):
"""
Restore termios settings to state captured in constructor.
"""
self.set_attr(self._tattr, termios.TCSADRAIN)
def __exit__(self, __exc_type: typing.Type[BaseException], __exc_value: BaseException,
__traceback: types.TracebackType) -> bool:
self.restore()
return False #__exc_type is not None and issubclass(__exc_type, termios.error)
def _task_from_event(evt: asyncio.Event, loop: asyncio.AbstractEventLoop = None):
if not loop:
loop = asyncio.get_running_loop()
#TODO: this is hacky
async def wait():
while not evt.is_set():
await asyncio.sleep(0.1)
return True
return loop.create_task(wait())
class AggregateException(Exception):
def __init__(self, inner_exceptions: [Exception]):
super().__init__()
self.inner_exceptions = inner_exceptions
def __str__(self):
return "Multiple exceptions encountered: \n\n" + "\n\n".join(map(lambda e: str(e), self.inner_exceptions))
async def event_wait_any(evts: [asyncio.Event], timeout: float = None) -> (any, any):
tasks = list(map(lambda evt: (evt, _task_from_event(evt)), evts))
try:
finished, unfinished = await asyncio.wait(map(lambda t: t[1], tasks),
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED)
if len(unfinished) > 0:
for task in unfinished:
task.cancel()
await asyncio.wait(unfinished)
exceptions = []
for f in finished:
ex = f.exception()
if ex and not isinstance(ex, asyncio.CancelledError) and not isinstance(ex, TimeoutError):
exceptions.append(ex)
if len(exceptions) > 0:
raise AggregateException(exceptions)
return next(map(lambda t: next(map(lambda tt: tt[0], tasks)), finished), None)
finally:
unfinished = []
for task in map(lambda t: t[1], tasks):
if task.done():
if not task.cancelled():
task.exception()
else:
task.cancel()
unfinished.append(task)
if len(unfinished) > 0:
await asyncio.wait(unfinished)
async def event_wait(evt: asyncio.Event, timeout: float) -> bool:
"""
Wait for event to be set, or timeout to expire.
:param evt: asyncio.Event to wait on
:param timeout: maximum number of seconds to wait.
:return: True if event was set, False if timeout expired
"""
await event_wait_any([evt], timeout=timeout)
return evt.is_set()
def _launch_child(cmd_line: list[str], env: dict[str, str], stdin_is_pipe: bool, stdout_is_pipe: bool,
stderr_is_pipe: bool) -> tuple[int, int, int, int]:
# Set up PTY and/or pipes
child_fd = parent_fd = None
if not (stdin_is_pipe and stdout_is_pipe and stderr_is_pipe):
parent_fd, child_fd = pty.openpty()
child_stdin, parent_stdin = (os.pipe() if stdin_is_pipe else (child_fd, parent_fd))
parent_stdout, child_stdout = (os.pipe() if stdout_is_pipe else (parent_fd, child_fd))
parent_stderr, child_stderr = (os.pipe() if stderr_is_pipe else (parent_fd, child_fd))
# Fork
pid = os.fork()
if pid == 0:
try:
# We are in the child process, so close all open sockets and pipes except for the PTY and/or pipes
max_fd = os.sysconf("SC_OPEN_MAX")
for fd in range(3, max_fd):
if fd not in (child_stdin, child_stdout, child_stderr):
try:
os.close(fd)
except OSError:
pass
# Set up PTY and/or pipes
os.dup2(child_stdin, 0)
os.dup2(child_stdout, 1)
os.dup2(child_stderr, 2)
# Make PTY controlling if necessary so that CTRL_C/CTRL_D behave as expected
if child_fd is not None:
os.setsid()
try:
tty_fd = 0 if not stdin_is_pipe else (1 if not stdout_is_pipe else 2)
# Set controlling TTY for this session
fcntl.ioctl(tty_fd, termios.TIOCSCTTY, 0)
except Exception:
pass
# Ensure the child is the foreground process group for the TTY
try:
os.setpgid(0, 0)
pgid = os.getpgrp()
import struct as _struct
fcntl.ioctl(tty_fd, termios.TIOCSPGRP, _struct.pack('i', pgid))
except Exception:
pass
# Ensure canonical input with signals and local echo enabled
try:
tty_fd = 0 if not stdin_is_pipe else (1 if not stdout_is_pipe else 2)
attrs = termios.tcgetattr(tty_fd)
lflag = attrs[3]
lflag |= termios.ICANON | termios.ISIG | termios.ECHO
attrs[3] = lflag
termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
except Exception:
pass
# Execute the command
os.execvpe(cmd_line[0], cmd_line, env)
except Exception as err:
exc_type, exc_obj, exc_tb = sys.exc_info()
fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
print(f"Unable to start {cmd_line[0]}: {err} ({fname}:{exc_tb.tb_lineno})")
sys.stdout.flush()
# don't let any other modules get in our way, do an immediate silent exit.
os._exit(255)
else:
# We are in the parent process, so close the child-side of the PTY and/or pipes
if child_fd is not None:
os.close(child_fd)
if child_stdin != child_fd:
os.close(child_stdin)
if child_stdout != child_fd:
os.close(child_stdout)
if child_stderr != child_fd:
os.close(child_stderr)
# # Close the write end of the pipe if a pipe is used for standard input
# if not stdin_is_pipe:
# os.close(parent_stdin)
# Return the child PID and the file descriptors for the PTY and/or pipes
return pid, parent_stdin, parent_stdout, parent_stderr
class CallbackSubprocess:
# time between checks of child process
PROCESS_POLL_TIME: float = 0.1
# Close pipes soon after process exit to avoid scheduling on closed event loops
PROCESS_PIPE_TIME: int = 1
def __init__(self, argv: [str], env: dict, loop: asyncio.AbstractEventLoop, stdout_callback: callable,
stderr_callback: callable, terminated_callback: callable, stdin_is_pipe: bool, stdout_is_pipe: bool,
stderr_is_pipe: bool):
"""
Fork a child process and generate callbacks with output from the process.
:param argv: the command line, tokenized. The first element must be the absolute path to an executable file.
:param env: environment variables to override
:param loop: the asyncio event loop to use
:param stdout_callback: callback for data, e.g. def callback(data:bytes) -> None
:param terminated_callback: callback for termination/return code, e.g. def callback(return_code:int) -> None
"""
assert loop is not None, "loop should not be None"
assert stdout_callback is not None, "stdout_callback should not be None"
assert terminated_callback is not None, "terminated_callback should not be None"
self._command: [str] = argv
self._env = env or {}
self._loop = loop
self._stdout_cb = stdout_callback
self._stderr_cb = stderr_callback
self._terminated_cb = terminated_callback
self._pid: int = None
self._child_stdin: int = None
self._child_stdout: int = None
self._child_stderr: int = None
self._return_code: int = None
self._stdout_eof: bool = False
self._stderr_eof: bool = False
self._stdin_is_pipe = stdin_is_pipe
self._stdout_is_pipe = stdout_is_pipe
self._stderr_is_pipe = stderr_is_pipe
self._at_line_start: bool = True
self._tty_line_buffer: bytearray = bytearray()
def _ensure_pipes_closed(self):
stdin = self._child_stdin
stdout = self._child_stdout
stderr = self._child_stderr
fds = set(filter(lambda x: x is not None, list({stdin, stdout, stderr})))
RNS.log(f"Queuing close of pipes for ended process (fds: {fds})", RNS.LOG_DEBUG)
def ensure_pipes_closed_inner():
RNS.log(f"Ensuring pipes are closed (fds: {fds})", RNS.LOG_DEBUG)
for fd in fds:
RNS.log(f"Closing fd {fd}", RNS.LOG_DEBUG)
with contextlib.suppress(OSError): tty_unset_reader_callbacks(fd)
with contextlib.suppress(OSError): os.close(fd)
self._child_stdin = None
self._child_stdout = None
self._child_stderr = None
# Avoid scheduling on a closed loop
if self._loop.is_closed(): ensure_pipes_closed_inner()
else: self._loop.call_later(CallbackSubprocess.PROCESS_PIPE_TIME, ensure_pipes_closed_inner)
def terminate(self, kill_delay: float = 1.0):
"""
Terminate child process if running
:param kill_delay: if after kill_delay seconds the child process has not exited, escalate to SIGHUP and SIGKILL
"""
RNS.log("terminate()", RNS.LOG_EXTREME)
if not self.running: return
with exception.permit(SystemExit): os.kill(self._pid, signal.SIGTERM)
def kill():
if process_exists(self._pid):
RNS.log("kill()", RNS.LOG_EXTREME)
with exception.permit(SystemExit):
os.kill(self._pid, signal.SIGHUP)
os.kill(self._pid, signal.SIGKILL)
self._loop.call_later(kill_delay, kill)
def wait():
RNS.log("wait()", RNS.LOG_EXTREME)
with contextlib.suppress(OSError): os.waitpid(self._pid, 0)
self._ensure_pipes_closed()
RNS.log("wait() finish", RNS.LOG_EXTREME)
threading.Thread(target=wait, daemon=True).start()
def close_stdin(self):
with contextlib.suppress(Exception):
os.close(self._child_stdin)
# Encourage prompt shutdown if child lingers after stdin close
def _ensure_terminate():
if self.running:
self.terminate(kill_delay=0.2)
if not self._loop.is_closed():
self._loop.call_later(0.05, _ensure_terminate)
@property
def started(self) -> bool:
"""
:return: True if child process has been started
"""
return self._pid is not None
@property
def running(self) -> bool:
"""
:return: True if child process is still running
"""
return self._pid is not None and process_exists(self._pid)
def write(self, data: bytes):
"""
Write bytes to the stdin of the child process.
:param data: bytes to write
"""
os.write(self._child_stdin, data)
# TODO: Check what this is actually supposed to solve.
#
# For pipe-in + TTY-out, echo should be visible immediately
if self._stdin_is_pipe and not self._stdout_is_pipe and self._stdout_cb is not None and data not in (CTRL_C, CTRL_D):
try: self._stdout_cb(data)
except Exception: pass
def set_winsize(self, r: int, c: int, h: int, v: int):
"""
Set the window size on the tty of the child process.
:param r: rows visible
:param c: columns visible
:param h: horizontal pixels visible
:param v: vertical pixels visible
:return:
"""
RNS.log(f"set_winsize({r},{c},{h},{v}", RNS.LOG_DEBUG)
tty_set_winsize(self._child_stdout, r, c, h, v)
def copy_winsize(self, fromfd: int):
"""
Copy window size from one tty to another.
:param fromfd: source tty file descriptor
"""
r, c, h, v = tty_get_winsize(fromfd)
self.set_winsize(r, c, h, v)
def tcsetattr(self, when: int, attr: list[any]): # actual type is list[int | list[int | bytes]]
"""
Set tty attributes.
:param when: when to apply change: termios.TCSANOW or termios.TCSADRAIN or termios.TCSAFLUSH
:param attr: attributes to set
"""
termios.tcsetattr(self._child_stdin, when, attr)
def tcgetattr(self) -> list[any]: # actual type is list[int | list[int | bytes]]
"""
Get tty attributes.
:return: tty attributes value
"""
return termios.tcgetattr(self._child_stdout)
def ttysetraw(self):
tty.setraw(self._child_stdout, termios.TCSADRAIN)
def start(self):
"""
Start the child process.
"""
RNS.log("start()", RNS.LOG_EXTREME)
# # Using the parent environment seems to do some weird stuff, at least on macOS
# parentenv = os.environ.copy()
# env = {"HOME": parentenv["HOME"],
# "PATH": parentenv["PATH"],
# "TERM": self._term if self._term is not None else parentenv.get("TERM", "xterm"),
# "LANG": parentenv.get("LANG"),
# "SHELL": self._command[0]}
env = os.environ.copy()
for key in self._env:
env[key] = self._env[key]
program = self._command[0]
assert isinstance(program, str)
# match = re.search("^/bin/(.*sh)$", program)
# if match:
# self._command[0] = "-" + match.group(1)
# env["SHELL"] = program
# self._log.debug(f"set login shell {self._command}")
self._pid, \
self._child_stdin, \
self._child_stdout, \
self._child_stderr = _launch_child(self._command, env, self._stdin_is_pipe, self._stdout_is_pipe,
self._stderr_is_pipe)
RNS.log(f"Started pid {self.pid}, fds: {self._child_stdin}, {self._child_stdout}, {self._child_stderr}", RNS.LOG_DEBUG)
def poll():
try:
pid, self._return_code = os.waitpid(self._pid, os.WNOHANG)
if self._return_code is not None:
self._return_code = self._return_code & 0xff
if self._return_code is not None and not process_exists(self._pid):
RNS.log(f"polled return code {self._return_code}", RNS.LOG_DEBUG)
self._terminated_cb(self._return_code)
if self.running:
self._loop.call_later(CallbackSubprocess.PROCESS_POLL_TIME, poll)
else:
self._ensure_pipes_closed()
except Exception as e:
if not hasattr(e, "errno") or e.errno != errno.ECHILD:
RNS.log(f"Error in process poll: {e}", RNS.LOG_DEBUG)
self._loop.call_later(CallbackSubprocess.PROCESS_POLL_TIME, poll)
def stdout():
try:
with exception.permit(SystemExit):
data = tty_read_poll(self._child_stdout)
if data is not None and len(data) > 0:
self._stdout_cb(data)
# Opportunistically drain shortly after to coalesce immediate follow-up output
if not self._loop.is_closed():
self._loop.call_later(0.01, stdout)
except EOFError:
self._stdout_eof = True
tty_unset_reader_callbacks(self._child_stdout)
self._stdout_cb(bytearray())
def stderr():
try:
with exception.permit(SystemExit):
data = tty_read_poll(self._child_stderr)
if data is not None and len(data) > 0:
self._stderr_cb(data)
if not self._loop.is_closed():
self._loop.call_later(0.01, stderr)
except EOFError:
self._stderr_eof = True
tty_unset_reader_callbacks(self._child_stderr)
self._stderr_cb(bytearray())
tty_add_reader_callback(self._child_stdout, stdout, self._loop)
if self._child_stderr != self._child_stdout:
tty_add_reader_callback(self._child_stderr, stderr, self._loop)
@property
def stdout_eof(self):
return self._stdout_eof or not self.running
@property
def stderr_eof(self):
return self._stderr_eof or not self.running
@property
def return_code(self) -> int:
return self._return_code
@property
def pid(self) -> int:
return self._pid
async def main():
"""
A test driver for the CallbackProcess class.
python ./process.py /bin/zsh --login
"""
if len(sys.argv) <= 1:
print(f"Usage: {sys.argv} <absolute_path_to_child_executable> [child_arg ...]")
exit(1)
loop = asyncio.get_event_loop()
# asyncio.set_event_loop(loop)
retcode = loop.create_future()
def stdout(data: bytes): os.write(sys.stdout.fileno(), data)
def terminated(rc: int): retcode.set_result(rc)
process = CallbackSubprocess(argv=sys.argv[1:],
env={"TERM": os.environ.get("TERM", "xterm")},
loop=loop,
stdout_callback=stdout,
terminated_callback=terminated)
def sigint_handler(sig, frame):
if process is None or process.started and not process.running:
raise KeyboardInterrupt
elif process.running:
process.write("\x03".encode("utf-8"))
def sigwinch_handler(sig, frame):
process.copy_winsize(sys.stdin.fileno())
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGWINCH, sigwinch_handler)
def stdin():
try:
data = tty_read(sys.stdin.fileno())
if data is not None:
process.write(data)
except EOFError:
tty_unset_reader_callbacks(sys.stdin.fileno())
process.write(CTRL_D)
tty_add_reader_callback(sys.stdin.fileno(), stdin)
process.start()
# call_soon called it too soon, not sure why.
loop.call_later(0.001, functools.partial(process.copy_winsize, sys.stdin.fileno()))
val = await retcode
RNS.log(f"Got return code {val}", RNS.LOG_DEBUG)
return val
if __name__ == "__main__":
tr = TTYRestorer(sys.stdin.fileno())
try:
tr.raw()
asyncio.run(main())
finally:
tty_unset_reader_callbacks(sys.stdin.fileno())
tr.restore()