问题描述
我有一个(遗留)程序,它充当守护进程(从某种意义上说,它永远运行以等待服务请求),但它有一个在主机上运行的基于 ncurses 的用户界面。
我想更改程序,以便如果我通过 ssh 连接到主机,我可以按需启用用户界面。 我知道至少有一种使用伪终端的方法,但我不太确定如何实现它。 我认为有两种应用行为很有趣:
仅当应用程序在终端的前台运行时才运行 UI
当有人连接到服务器时按需创建一个新的 UI
注意事项
使用 screen 有一种简单的方法可以做到这一点。所以:
原文:
screen mydaemon etc...
新的 ssh 会话:
screen -d
screen -r
这会分离屏幕,让它在后台运行,然后将其重新连接到当前终端。关闭终端后,屏幕会话将分离,因此效果很好。
我想了解屏幕在幕后的作用,既是为了我自己的教育,也是为了了解您将如何将某些功能放入应用程序本身。
我知道如何为通过套接字连接的服务器执行此操作。我想了解的是原则上如何使用伪终端来做到这一点。这确实是使应用程序工作的一种奇怪方式,但我认为它有助于深入探索使用伪终端的力量和局限性。
对于第一种情况,我假设我希望 ncurses UI 在从属终端中运行,主控端将输入传入和传出。
主进程会使用 isatty() 之类的东西来检查它当前是否在终端的前台,并使用 newterm() 和 endwin() 激活或停用 UI。
我一直在试验这个,但我还没有让它工作,因为终端和 ncurses 的某些方面我充其量还没有掌握,最坏的情况是根本性的误解。
这里的伪代码是:
openpty(masterfd,slavefd)
login_tty();
fork();
ifslave
close(stdin)
close(stdout)
dup_a_new_stdin_from_slavefd();
newterm(NULL,newinfd,newoutfd); (
printw("hello world");
insert_uiloop_here();
endwin();
else ifmaster
catchandforwardtoslave(SIGWINCH);
while(noexit)
{
docommswithslave();
forward_output_as_appropriate();
}
通常我要么得到 segfault inside fileno_unlocked() in newterm() 或在调用终端而不是新的不可见终端上输出。
问题
- 上面的伪代码有什么问题?
- 我的主从端是否以正确的方式运行?
- login_tty 在这里实际做什么?
- openpty() + login_tty() 与 posix_openpt() + grantpt() 之间有什么实际区别吗?
- 是否必须始终有一个正在运行的进程与主 tty 或从属主 tty 相关联?
注意:这是一个与 ncurses-newterm-following-openpty 不同的问题,它描述了此用例的特定不正确/不完整的实现,并询问它有什么问题。
解决方法
这是一个很好的问题,也是我们为什么有伪终端的一个很好的例子。
为了使守护进程能够使用 ncurses 接口,它需要一个伪终端(伪终端对的从端),从守护进程开始执行的那一刻开始一直可用,直到守护进程退出。
要存在伪终端,必须有一个进程对伪终端对的主端有一个开放的描述符。此外,它必须消耗来自伪终端从属端的所有输出(ncurses 输出的可见内容)。通常,像 vterm 这样的库用于解释该输出以将实际文本帧缓冲区“绘制”到一个数组中(好吧,通常是两个数组 - 一个用于显示在每个单元格(特定行和列)中的宽字符,另一个用于颜色等属性)。
为了使伪终端对正常工作,要么master端的进程是slave端运行ncurses的进程的父进程或祖先,要么两者完全无关。在从端运行 ncurses 的进程应该在一个新的会话中,伪终端作为它的控制终端。这是最容易实现的,如果我们使用一个小的伪终端“服务器”在子进程中启动守护进程;事实上,这就是通常与伪终端一起使用的模式。
第一种情况不太可行,因为没有父/主进程维护伪终端。
我们可以提供第一个场景的行为,通过添加一个小的伪终端提供“看门人”进程,其任务是维持伪终端对的存在,并消耗任何生成的 ncurses 输出通过伪终端对中运行的进程。
然而,这种行为也符合第二种情况。
换句话说,这是可行的:
-
我们没有直接启动守护进程,而是使用一个自定义程序,比如“janitor”,它创建一个伪终端并在该伪终端内运行守护进程。
-
只要守护程序运行,Janitor 就会一直运行。
-
Janitor 为其他进程“连接”到伪终端对的主端提供了一个接口。
这并不一定意味着 1:1 的数据代理。通常提供给守护程序的输入(按键)是未经修改的,但是伪终端“帧缓冲区”的内容(基于字符的虚拟窗口内容)的传输方式确实有所不同。这完全在我们自己的控制之下。
-
要连接到管理员,我们需要第二个帮助程序。
就'screen'而言,这两个程序实际上是同一个二进制文件;行为仅由命令行参数控制,按键由“屏幕”本身“消耗”,以控制“屏幕”行为,而不是传递给在伪终端中运行的实际基于 ncurses 的进程。
到目前为止,我们可以只检查 tmux 或 screen 来源以了解它们如何执行上述操作;这是非常简单的终端多路复用的东西。
然而,这里有一个我以前没有考虑过的非常有趣的地方;这一点让我明白了这个问题相当重要的核心:
多个用户可以拥有自己的 UI 实例。
一个进程只能有一个控制终端。这指定了某种关系。例如,当控制终端的主端关闭时,伪终端对消失,对伪终端对的从端开放的描述符变得不起作用(如果我没记错的话,所有操作都会产生 EIO);但更重要的是,进程组中的每个进程都会收到一个 HUP 信号。
ncurses newterm() 函数允许进程在运行时连接到现有终端或伪终端。该终端不需要是控制终端,使用 ncurses 的进程也不需要属于该会话。重要的是要认识到,在这种情况下,标准流(标准输入、输出和错误)不会重定向到终端。
所以,如果有办法告诉守护进程它有一个新的伪终端可用,并且应该打开它,因为有一个用户想要使用守护进程提供的接口,我们可以让守护进程打开并关闭按需伪终端!
但是请注意,这需要守护程序与用于连接到守护程序提供的基于 ncurses 的 UI 的进程之间的明确合作。对于任意的基于 ncurses 的进程或守护进程,没有标准的方法来做到这一点。例如,据我所知,nano
和top
没有提供这样的接口;他们只使用与标准流相关的伪终端。
发布此答案后——希望在问题结束之前足够快,因为其他人看不到问题的有效性,以及它对其他服务器端 POSIXy 开发人员的用处——我将构建一个示例程序对来举例说明上述问题;可能使用 Unix 域套接字作为“此用户的新 UI,请”通信通道,因为文件描述符可以作为使用 Unix 域套接字的辅助数据传递,并且可以验证套接字两端的用户身份(凭据辅助数据)。
但是,现在,让我们回到提出的问题。
上面的伪代码有什么问题? [通常我要么在 newterm() 中的 fileno_unlocked() 中得到段错误,要么在调用终端而不是新的隐形终端上输出。]
newinfd
和 newoutfd
应该与伪终端从端文件描述符 slavefd
相同(或 dup()s)。
我认为还应该有一个显式的 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()
+ 打开伪终端对的slave + 可选设置termios 设置和终端窗口大小。
使用 BSD 接口,login_tty
在子进程中运行。它运行 setsid()
以创建新会话,使从属端成为控制终端,将标准流重定向到控制终端的从属端,并关闭主端描述符的副本。
在 BSD 接口中,forkpty()
结合了 openpty()
、fork()
和 login_tty()
。它返回两次;一次在父进程中(返回子进程的 PID),一次在子进程中(返回零)。子进程在一个新会话中运行,伪终端从端作为其控制终端,已经重定向到标准流。
openpty() + login_tty() 与 posix_openpt() + grantpt() [ + unlockpt() + 打开从机端] 之间有什么实际区别吗?
不,不是真的。
Linux 和大多数 BSD 都倾向于提供两者。 (在Linux中,使用BSD接口时,需要在libutil库中链接(-lutil
gcc选项),但它是由提供标准C库的同一个包提供的,可以假设总是可用。)
我倾向于更喜欢 POSIX 接口,尽管它更冗长,但除了比 BSD 接口更喜欢 POSIX 接口之外,我什至不知道为什么我更喜欢它而不是 BSD 接口。 BSD forkpty()
基本上可以在一次调用中完成最常见用例的所有操作!
此外,我倾向于首先尝试使用特定于 Linux 的 ioctl(如果它看起来可用),而不是依赖于 ptsname()
(或 GNU ptsname_r() 扩展),然后回退到 {{1}如果它不可用。所以,如果有的话,我可能应该更喜欢 BSD 界面..但是 ptsname()
有点让我烦恼,我想,所以我没有。
我绝对不反对其他人更喜欢 BSD 界面。如果有的话,我对我的偏好如何存在感到有点困惑。通常我更喜欢更简单、更健壮的界面而不是冗长、复杂的界面。
是否必须始终有一个正在运行的进程与主 tty 或从属主 tty 相关联?
必须有一个进程打开了伪终端的主端。当描述符的最后一个副本关闭时,内核会销毁该对。
此外,如果具有主端描述符的进程不从中读取,则在伪终端中运行的进程将意外阻塞某些 ncurses 调用。通常,呼叫不会阻塞(或仅阻塞很短的持续时间,比人们注意到的要短)。如果进程只是读取但丢弃了输入,那么我们实际上并不知道 ncurses 终端的内容!
因此,我们可以说,绝对需要一个进程从伪终端对主机端读取数据,并保持一个描述符对主机端开放。
(slave端不同;因为字符设备节点通常是可见的,一个进程可以暂时关闭它与伪终端的连接,稍后重新打开它。在Linux中,当没有进程有一个打开的slave端描述符时,从主端读取或写入主端的进程将得到 EIO 错误(read() 和 write() 返回 -1 且 errno==EIO)。不过,我不确定这是否是有保证的行为;还没有迄今为止一直依赖它,直到最近(在实现示例时)我才注意到它。
,以下是一个 ncurses 应用程序示例,该应用程序在作为参数提供的每个终端上制作一个弹跳 X 的动画:
// SPDX-License-Identifier: CC0-1.0
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <sys/ioctl.h>
#include <locale.h>
#include <curses.h>
#include <time.h>
#include <string.h>
#include <signal.h>
#include <stdio.h>
#include <errno.h>
#ifndef FRAMES_PER_SECOND
#define FRAMES_PER_SECOND 25
#endif
#define FRAME_DURATION (1.0 / (double)(FRAMES_PER_SECOND))
/* Because the terminals are not the controlling terminal for this process,* this process may not receive the SIGWINCH signal whenever a screen size
* changes. Therefore,we call this function to update it whenever we switch
* between terminals.
*/
extern void _nc_update_screensize(SCREEN *);
/*
* Signal handler to notice if this program - all its terminals -- should exit.
*/
static volatile sig_atomic_t done = 0;
static void handle_done(int signum)
{
done = signum;
}
static int install_done(int signum)
{
struct sigaction act;
memset(&act,sizeof act);
sigemptyset(&act.sa_mask);
act.sa_handler = handle_done;
act.sa_flags = 0;
return sigaction(signum,&act,NULL);
}
/* Difference in seconds between to timespec structures.
*/
static inline double difftimespec(const struct timespec after,const struct timespec before)
{
return (double)(after.tv_sec - before.tv_sec)
+ (double)(after.tv_nsec - before.tv_nsec) / 1000000000.0;
}
/* Sleep the specified number of seconds using nanosleep().
*/
static inline double nsleep(const double seconds)
{
if (seconds <= 0.0)
return 0.0;
const long sec = (long)seconds;
long nsec = (long)(1000000000.0 * (seconds - (double)sec));
if (nsec < 0)
nsec = 0;
if (nsec > 999999999)
nsec = 999999999;
if (sec == 0 && nsec < 1)
return 0.0;
struct timespec req = { .tv_sec = (time_t)sec,.tv_nsec = nsec };
struct timespec rem = { .tv_sec = 0,.tv_nsec = 0 };
if (nanosleep(&req,&rem) == -1 && errno == EINTR)
return (double)(rem.tv_sec) + (double)(rem.tv_nsec) / 1000000000.0;
return 0.0;
}
/*
* Structure describing each client (terminal) state.
*/
struct client {
SCREEN *term;
FILE *in;
FILE *out;
int col; /* Ball column */
int row; /* Ball row */
int dcol; /* Ball direction in column axis */
int drow; /* Ball direction in row axis */
};
static size_t clients_max = 0;
static size_t clients_num = 0;
static struct client *clients = NULL;
/* Add a new terminal,based on device path,and optionally terminal type.
*/
static int add_client(const char *ttypath,const char *term)
{
if (!ttypath || !*ttypath)
return errno = EINVAL;
if (clients_num >= clients_max) {
const size_t temps_max = (clients_num | 15) + 13;
struct client *temps;
temps = realloc(clients,temps_max * sizeof clients[0]);
if (!temps)
return errno = ENOMEM;
clients_max = temps_max;
clients = temps;
}
clients[clients_num].term = NULL;
clients[clients_num].in = NULL;
clients[clients_num].out = NULL;
clients[clients_num].col = 0;
clients[clients_num].row = 0;
clients[clients_num].dcol = +1;
clients[clients_num].drow = +1;
clients[clients_num].in = fopen(ttypath,"r+");
if (!clients[clients_num].in)
return errno;
clients[clients_num].out = fopen(ttypath,"r+");
if (!clients[clients_num].out) {
const int saved_errno = errno;
fclose(clients[clients_num].in);
return errno = saved_errno;
}
clients[clients_num].term = newterm(term,clients[clients_num].in,clients[clients_num].out);
if (!clients[clients_num].term) {
fclose(clients[clients_num].out);
fclose(clients[clients_num].in);
return errno = ENOMEM;
}
set_term(clients[clients_num].term);
start_color();
cbreak();
noecho();
nodelay(stdscr,TRUE);
keypad(stdscr,TRUE);
scrollok(stdscr,FALSE);
curs_set(0);
clear();
refresh();
clients_num++;
return 0;
}
static void close_all_clients(void)
{
while (clients_num > 0) {
clients_num--;
if (clients[clients_num].term) {
set_term(clients[clients_num].term);
endwin();
delscreen(clients[clients_num].term);
clients[clients_num].term = NULL;
}
if (clients[clients_num].in) {
fclose(clients[clients_num].in);
clients[clients_num].in = NULL;
}
if (clients[clients_num].out) {
fclose(clients[clients_num].out);
clients[clients_num].out = NULL;
}
}
}
int main(int argc,char *argv[])
{
struct timespec curr,prev;
int arg;
if (argc < 2 || !strcmp(argv[1],"-h") || !strcmp(argv[1],"--help")) {
const char *arg0 = (argc > 0 && argv && argv[0] && argv[0][0]) ? argv[0] : "(this)";
fprintf(stderr,"\n");
fprintf(stderr,"Usage: %s [ -h | --help ]\n",arg0);
fprintf(stderr," %s TERMINAL [ TERMINAL ... ]\n","This program displays a bouncing ball animation in each terminal.\n");
fprintf(stderr,"Press Q or . in any terminal,or send this process an INT,HUP,\n");
fprintf(stderr,"QUIT,or TERM signal to quit.\n");
fprintf(stderr,"\n");
return EXIT_SUCCESS;
}
setlocale(LC_ALL,"");
for (arg = 1; arg < argc; arg++) {
if (add_client(argv[arg],NULL)) {
fprintf(stderr,"%s: %s.\n",argv[arg],strerror(errno));
close_all_clients();
return EXIT_FAILURE;
}
}
if (install_done(SIGINT) == -1 ||
install_done(SIGHUP) == -1 ||
install_done(SIGQUIT) == -1 ||
install_done(SIGTERM) == -1) {
fprintf(stderr,"Cannot install signal handlers: %s.\n",strerror(errno));
close_all_clients();
return EXIT_FAILURE;
}
clock_gettime(CLOCK_MONOTONIC,&curr);
while (!done && clients_num > 0) {
size_t n;
/* Wait until it is time for the next frame. */
prev = curr;
clock_gettime(CLOCK_MONOTONIC,&curr);
nsleep(FRAME_DURATION - difftimespec(curr,prev));
/* Update each terminal. */
n = 0;
while (n < clients_num) {
int close_this_terminal = 0;
int ch,rows,cols;
set_term(clients[n].term);
/* Because the terminal is not our controlling terminal,we may miss SIGWINCH window size change signals.
To work around that,we explicitly check it here. */
_nc_update_screensize(clients[n].term);
/* Process inputs - if we get any */
while ((ch = getch()) != ERR)
if (ch == 'x' || ch == 'X' || ch == 'h' || ch == 'H')
clients[n].dcol = -clients[n].dcol;
else
if (ch == 'y' || ch == 'Y' || ch == 'v' || ch == 'V')
clients[n].drow = -clients[n].drow;
else
if (ch == '.' || ch == 'q' || ch == 'Q')
close_this_terminal = 1;
if (close_this_terminal) {
endwin();
delscreen(clients[n].term);
fclose(clients[n].in);
fclose(clients[n].out);
/* Remove from array. */
clients_num--;
clients[n] = clients[clients_num];
clients[clients_num].term = NULL;
clients[clients_num].in = NULL;
clients[clients_num].out = NULL;
continue;
}
/* Obtain current terminal size. */
getmaxyx(stdscr,cols);
/* Leave a trace of dots. */
if (clients[n].row >= 0 && clients[n].row < rows &&
clients[n].col >= 0 && clients[n].col < cols)
mvaddch(clients[n].row,clients[n].col,'.');
/* Top edge bounce. */
if (clients[n].row <= 0) {
clients[n].row = 0;
clients[n].drow = +1;
}
/* Left edge bounce. */
if (clients[n].col <= 0) {
clients[n].col = 0;
clients[n].dcol = +1;
}
/* Bottom edge bounce. */
if (clients[n].row >= rows - 1) {
clients[n].row = rows - 1;
clients[n].drow = -1;
}
/* Right edge bounce. */
if (clients[n].col >= cols - 1) {
clients[n].col = cols - 1;
clients[n].dcol = -1;
}
clients[n].row += clients[n].drow;
clients[n].col += clients[n].dcol;
mvaddch(clients[n].row,'X');
refresh();
/* Next terminal. */
n++;
}
}
close_all_clients();
return EXIT_SUCCESS;
}
这不包含伪终端,唯一真正的怪癖是使用 _nc_update_screensize()
来检测是否有任何终端发生了变化。 (因为它们不是我们的控制终端,所以我们没有收到 SIGWINCH 信号,因此 ncurses 错过了窗口变化。)
我建议使用 gcc -Wall -Wextra -O2 bounce.c -lncurses -o bounce
编译它。
打开几个终端窗口,然后运行 tty
以查看其控制终端的路径(通常是伪终端的从端,/dev/pts/N
)。
以这些路径中的一个或多个作为参数运行 ./bounce
,然后开始弹跳。
如果您不希望窗口中的 shell 使用您的输入,并且希望上述程序看到它,请运行例如sleep 6000
在运行上述命令之前在终端窗口中。
这个程序简单地向每个终端打开两个流,并让 ncurses 控制它们;基本上,它是一个多终端 ncurses 应用程序的示例,以及如何使用 newterm()
、set_term()
等来处理它们。
如果您多次提供同一个终端,按 Q 会以随机顺序关闭它们,因此 ncurses 可能无法正确地将终端恢复到原始状态。 (您可能需要盲目键入 reset
,将终端重置为可用状态;它是 clear
的伴随命令,它只是清除终端。他们不做任何其他事情,只是终端的东西.)
该程序不是将终端设备的路径作为命令行参数提供,而是可以一直运行,但侦听传入的 Unix 域数据报,带有 SOL_SOCKET 级别的 SCM_RIGHTS 类型的辅助数据,可以用于在不相关的进程之间复制文件描述符。
然而,如果像这样放弃对终端的控制(通过打开终端,或通过将终端文件描述符传递给另一个进程),问题是不可能撤销该访问。我们可以通过在两者之间使用伪终端并在伪终端和我们的真实终端之间代理数据来避免这种情况。要断开连接,我们只需停止代理数据并销毁伪终端对,然后将终端恢复到其初始状态。
检查上面的程序,我们看到控制一个新终端的伪代码过程是
-
获取终端的两个 FILE 流句柄。
上面的程序使用
fopen()
像平常一样打开它们。其他程序可以使用dup()
复制单个描述符,并使用fdopen()
将它们转换为 stdio FILE 流句柄。 -
调用
SCREEN *term = newterm(NULL,in,out)
让 ncurses 知道这个新终端。in
和out
是两个 FILE 流句柄。第一个参数是终端类型字符串;如果为 NULL,则使用 TERM 环境变量。今天的典型值是xterm-256color
,但 ncurses 也支持许多其他类型的终端。 -
调用
set_term(term)
使新终端成为当前活动的终端。此时,我们可以进行正常的 ncurses 设置,例如
cbreak(); noecho();
等。
重新控制终端也很简单:
-
调用
set_term(term)
使该终端成为当前活动的终端。 -
调用
endwin()
和delscreen(term)
。 -
关闭终端的两个 FILE 流。
更新终端内容需要一个循环,每次迭代处理一个终端,从 set_term(term)
调用开始(如果我们希望对这些终端中的窗口大小变化做出反应,紧接着是 _nc_update_screensize(term)
调用) .
上面的示例程序使用 nodelay()
模式,因此 getch()
将返回按键,如果当前终端没有未决输入,则返回 ERR
。 (至少在 Linux 中,只要窗口大小发生变化,我们就会得到 KEY_RESIZE
,只要终端是我们的控制终端,或者我们调用 _nc_update_screensize()
。)
但请注意:如果还有其他进程也在从该终端读取数据,例如 shell,则任何进程都可以读取输入。