这是一个很好的问题,也是我们为什么有伪终端的一个很好的例子。
为了让守护程序能够使用 ncurses 接口,它需要一个伪终端(伪终端对的从端),从守护程序开始执行的那一刻起,它就一直可用,直到守护程序退出为止。
为了存在一个伪终端,必须有一个进程对伪终端对的主端有一个开放的描述符。此外,它必须消耗来自伪终端从属端的所有输出(ncurses 输出的可见内容)。通常,像 vterm 这样的库用于解释输出以将实际文本帧缓冲区“绘制”到一个数组中(嗯,通常是两个数组 - 一个用于显示在每个单元格(特定行和列)中的宽字符,另一个用于颜色等属性)。
为了使伪终端对正常工作,要么主端的进程是从端运行 ncurses 的进程的父进程或祖先,要么两者完全不相关。从端运行ncurses的进程应该在一个新的会话中,以伪终端作为它的控制终端。如果我们使用在子进程中启动守护进程的小型伪终端“服务器”,这是最容易实现的;事实上,这是通常与伪终端一起使用的模式。
第一种情况并不真正可行,因为没有父/主进程维护伪终端。
我们可以提供第一个场景的行为,通过添加一个小的伪终端提供“看门人”进程,其任务是维持伪终端对的存在,并消耗任何生成的 ncurses 输出通过在伪终端对中运行的进程。
但是,这种行为也符合第二种情况。
换一种说法,这是可行的:
-
我们不是直接启动守护进程,而是使用自定义程序,比如“看门人”,它创建一个伪终端并在该伪终端内运行守护进程。
-
只要守护程序运行,Janitor 就会一直运行。
-
Janitor 为其他进程“连接”到伪终端对的主端提供了一个接口。
这并不一定意味着数据的 1:1 代理。通常提供给守护程序的输入(按键)未经修改,但是伪终端“帧缓冲区”的内容(基于字符的虚拟窗口内容)的传输方式却有所不同。这完全在我们自己的控制之下。
-
要连接到管理员,我们需要第二个帮助程序。
在'screen'的情况下,这两个程序实际上是同一个二进制文件;该行为仅由命令行参数控制,并且按键由“屏幕”本身“消耗”以控制“屏幕”行为,而不是传递给在伪终端中运行的基于 ncurses 的实际进程。
到目前为止,我们可以只检查tmux 或screen 来源,看看他们是如何做到以上几点的;这是非常简单的终端多路复用的东西。
然而,这里有一个非常有趣的地方,我以前没有考虑过;这一点点让我明白了这个问题相当重要的核心:
多个用户可以拥有自己的 UI 实例。
一个进程只能有一个控制终端。这指定了某种关系。例如,当控制终端的主端关闭时,伪终端对消失,向伪终端对的从端打开的描述符变得不起作用(如果我没记错的话,所有操作都会产生 EIO);但更重要的是,进程组中的每个进程都会收到一个 HUP 信号。
ncurses newterm() 函数允许进程在运行时连接到现有终端或伪终端。该终端不需要是控制终端,ncurses-using 进程也不需要属于该会话。重要的是要意识到在这种情况下,标准流(标准输入、输出和错误)不会重定向到终端。
所以,如果有办法告诉守护程序它有一个新的伪终端可用,并且应该打开它,因为有一个用户想要使用守护程序提供的接口,我们可以让守护程序打开和关闭按需提供伪终端!
但是请注意,这需要守护程序与用于连接到守护程序提供的基于 ncurses 的 UI 的进程之间的明确合作。对于任意基于 ncurses 的进程或守护进程,没有标准的方法来执行此操作。比如,据我所知nano和top没有提供这样的接口;他们只使用与标准流相关的伪终端。
发布此答案后——希望在问题结束之前足够快,因为其他人看不到问题的有效性,以及它对其他服务器端 POSIXy 开发人员的有用性——我将构建一个示例程序对来举例说明上述内容;可能使用 Unix 域套接字作为“此用户的新 UI,请”通信通道,因为文件描述符可以使用 Unix 域套接字作为辅助数据传递,并且可以验证套接字任一端的用户身份(凭据辅助数据)。
但是,现在,让我们回到之前提出的问题。
上面的伪代码有什么问题? [通常我要么在 newterm() 中的 fileno_unlocked() 中得到一个段错误,要么在调用终端而不是新的不可见终端上输出。]
newinfd 和 newoutfd 应该是相同的(或 dup()s)伪终端从端文件描述符 slavefd。
我认为还应该有一个显式的set_term(),并将 newterm() 返回的 SCREEN 指针作为参数。 (它可能会自动调用 newterm() 提供的第一个终端,但我宁愿显式调用它。)
newterm() 连接并准备一个新终端。这两个描述符通常都指一个伪终端对的同一个从端; infd 可以是其他一些接收用户按键的描述符。
一次只能在 ncurses 中激活一个终端。您需要使用set_term() 来选择哪一个会受到printw() 等调用的影响。 (它返回之前处于活动状态的终端,以便可以对另一个终端进行更新,然后返回到原始终端。)
(这也意味着如果一个程序提供多个终端,它必须在它们之间循环,检查输入,并以相对较高的频率更新每个终端,以便人类用户感觉 UI 是响应式的,而不是“滞后"。但是,狡猾的 POSIX 程序员可以选择或轮询底层描述符,并且只能循环通过有输入待处理的终端。)
我是否有正确的主从端?
是的,我相信你会的。从端是看到终端的,并且可以使用ncurses。主端是提供按键功能的端,它对 ncurses 输出进行某些操作(例如,将它们绘制到基于文本的帧缓冲区,或代理到远程终端)。
login_tty 在这里实际上做了什么?
有两种常用的伪终端接口:UNIX98(在 POSIX 中标准化)和 BSD。
使用 POSIX 接口,posix_openpt() 创建一个新的伪终端对,并将描述符返回到其主控端。关闭此描述符(最后打开的副本)会破坏该对。在 POSIX 模型中,从属端最初是“锁定的”,并且无法打开。 unlockpt() 移除了这个锁,允许从端打开。 grantpt() 更新字符设备(对应于伪终端对的从端)所有权和模式以匹配当前真实用户。 unlockpt() 和grantpt() 可以按任意顺序调用,但首先调用grantpt() 是有意义的;这样,在正确设置其所有权和访问模式之前,从属端不能被其他进程“意外”打开。 POSIX 通过ptsname() 提供了与伪终端对的从端对应的字符设备的路径,但Linux 提供了一个TIOCGPTPEER ioctl(在内核4.13 及更高版本中)允许打开从端,即使字符设备节点不是显示在当前挂载命名空间中。
通常,grantpt()、unlockpt() 和打开伪终端对的从属端是在使用setsid() 启动新会话的子进程(仍然可以访问主端描述符)中完成的.子进程将标准流(标准输入、输出和错误)重定向到伪终端的从端,关闭它的主端描述符副本,并确保伪终端是它的控制终端。通常这之后是执行将使用伪终端(通常通过 ncurses)作为其用户界面的二进制文件。
使用 BSD 接口,openpty() 创建伪终端对,为双方提供打开的文件描述符,并可选择设置伪终端 termios 设置和窗口大小。它大致对应于 POSIX posix_openpt() + grantpt() + unlockpt() + 打开伪终端对的从端 + 可选设置 termios 设置和终端窗口大小。
使用 BSD 接口,login_tty 在子进程中运行。它运行setsid()创建一个新会话,使从端成为控制终端,将标准流重定向到控制终端的从端,并关闭主端描述符的副本。
使用 BSD 接口,forkpty() 结合了 openpty()、fork() 和 login_tty()。它返回两次;一次在父进程中(返回子进程的 PID),一次在子进程中(返回零)。子进程在一个新会话中运行,伪终端从端作为它的控制终端,已经重定向到标准流。
openpty() + login_tty() vs posix_openpt() + grantpt() [ + unlockpt() + 打开从端] 有什么实际区别吗?
不,不是。
Linux 和大多数 BSD 都倾向于同时提供两者。 (在 Linux 中,使用 BSD 接口时,需要在 libutil 库中链接(-lutil gcc 选项),但它是由提供标准 C 库的同一包提供的,可以假设始终可用。 )
我倾向于更喜欢 POSIX 接口,尽管它更冗长,但除了有点喜欢 POSIX 接口而不是 BSD 接口之外,我什至不知道为什么我更喜欢它而不是 BSD 接口。 BSD forkpty() 基本上可以在一次调用中为最常见的用例完成所有工作!
另外,我不依赖ptsname()(或 GNU ptsname_r() 扩展),而是倾向于先尝试 Linux 特定的 ioctl(如果它看起来可用),如果可用则回退到 ptsname()无法使用。所以,如果有的话,我可能更喜欢 BSD 界面.. 但是libutil 有点让我烦恼,我猜,所以我不喜欢。
我绝对不反对其他人更喜欢 BSD 界面。如果有的话,我对我的偏好如何存在感到有点困惑;通常我更喜欢更简单、更健壮的接口,而不是冗长、复杂的接口。
是否必须始终有一个与主 tty 或从属主 tty 关联的正在运行的进程?
必须有一个进程打开伪终端的主端。当描述符的最后一个副本关闭时,内核会销毁该对。
另外,如果具有主端描述符的进程没有从中读取,则在伪终端中运行的进程将在某些 ncurses 调用中意外阻塞。通常,呼叫不会阻塞(或仅阻塞非常短的持续时间,比人类注意到的要短)。如果进程只是读取但丢弃了输入,那么我们实际上并不知道 ncurses 终端的内容!
因此,我们可以说,绝对需要有一个从伪终端对主端读取的进程,保持对主端开放的描述符。
(slave 端不同;因为字符设备节点通常是可见的,进程可以暂时关闭它与伪终端的连接,稍后再重新打开它。在 Linux 中,当没有进程对从端有打开的描述符时, 读取或写入主控端的进程将出现 EIO 错误(read() 和 write() 返回 -1 且 errno==EIO)。不过,我不确定这是否是有保证的行为;还没有到目前为止一直依赖它,并且我最近才注意到它(在实现示例时)。