-1

I'm working on a console C/C++ program on linux (centOS7) where some info need to be displayed on top of the terminal screen. While the main thread processes stdin, another thread handles callbacks and displays status on stdout. To avoid clobbering, callback status is only displayed on top of the screen, but the cursor must be returned to the original position.

I tried ANSI save/restore cursor but it doesn't work as pointed out in the link. While this stackoverflow solution works in single-thread, it doesn't work in multi-thread as the two thread would both read stdin. I have tried several methods to disable stdin temporarily while getting current cursor positions, but they all failed:

  • disable CREAD in termios.c_cflag -- tcsetattr() returns error (invalid parameter)
  • tcflow(TCIOFF)
  • dup()

I know ncurses would work, but in my app there are too many stdio functions that I need to replace with ncurses wrappers. Does anyone know how to save/restore cursor position or get current position in multithread env where one thread is reading stdin?

zhao
  • 232
  • 3
  • 15

2 Answers2

1

I know ncurses would work, but in my app there are too many stdio functions that I need to replace with ncurses wrappers.

So, you are not interested in fixing the problem, only in papering over it. One approach you can try is

    flockfile(stdin);
    flockfile(stdout);
    flockfile(stderr);
    /* Write ("\033[6n") to standard input,
       and read the ("\033[" row ";" column "R") response */
    funlockfile(stderr);
    funlockfile(stdout);
    funlockfile(stdin);

See man 3 flockfile() for details. The idea is to grab the C library internal lock for all three standard streams, so that any other thread doing I/O on them will block until we call funlockfile() on that stream.

This does not affect low-level I/O to STDIN_FILENO, STDOUT_FILENO, or STDERR_FILENO in any way.


A comment from rici made me realize that there is actually one approach that does not involve rewriting the original code.

Use a helper process (or thread) to handle all I/O to standard input, standard output, standard error, and the terminal.

Essentially, at the very start of your program, you construct three pipes and an Unix domain datagram socket pair, and create the helper.

(If you use a helper process, you can make it into an external executable, and write it using ncurses, without affecting the parent program.)

The helper is connected via the pipes and the socket pair to the parent process. Parent replaces STDIN_FILENO, STDOUT_FILENO, and STDERR_FILENO descriptors with the pipe ends (closing their respective original descriptors). That way, it can only read from the helper, and write to the helper, not directly to the original streams.

The Unix domain datagram socket pair allows the parent to query the current cursor location (and perhaps do other similar actions) from the helper.

The helper reads from two of the parent pipes and the original standard input, and writes to one of the parent pipe and the original standard output and error. I personally would make the helper pipe ends nonblocking, and use select(), so a single-threaded helper would suffice.

Nominal Animal
  • 38,216
  • 5
  • 59
  • 86
  • 1
    If you try to `flockfile(stdin)` and there is a `fread` (or other input request) pending on `stdin`, I would expect `flockfile` to block, not to somehow preempt the pending input request. – rici Jan 13 '19 at 03:49
  • @rici: True; there is also the issue of input arriving just before the operation above, in which case the `read()` reads not the terminal response, but the input. You cannot avoid that without overhauling the entire I/O to standard input. I just did not bother to state all those, because OP is not interested in a proper solution, only in the simplest band-aid they can get away with. With their stated limitations, true solutions do not exist. I wonder if it would be better to delete this answer? As it is, it could mislead others into writing poor, fragile code? – Nominal Animal Jan 13 '19 at 05:13
  • Actually, one actual solution might work: at the beginning of the program, fork a helper thread, and redirect standard input, output, and error to that. Let that thread handle the I/O, including parsing terminal responses. It does a lot of extra work, but it avoids having to edit the existing code. – Nominal Animal Jan 13 '19 at 05:16
  • That's certainly the solution I would go with. If the helper used ncurses to maintain a status panel and independent console panel, there would be no need to even mess around with cursir queries. – rici Jan 13 '19 at 05:20
  • Of course I'm interested in fixing the problem, but practically there are some difficulties. Since there's a lot of legacy code that I'm not familiar with, I want to make minimal change to existing code. Existing code uses readline and ansi escape strings for nice terminal display, and this all make dup() and pipe() broken.. – zhao Jan 13 '19 at 06:29
  • @NominalAnimal I tried dup() and pipe() but it didn't work. The problem is that there is only one stdin. User typing and callback are completely independent so there's no way to prevent them from interleaving. – zhao Jan 13 '19 at 08:51
  • @zhao: The second approach works **if** you isolate stdin, stdout, and stderr access to the helper process (or thread) **only**, and have the process itself communicate with the helper, and not directly to the terminal. In the helper process, there is no unexpected interleaving. – Nominal Animal Jan 14 '19 at 00:29
  • @NominalAnimal I know what you are talking about and that was my original thought as well. However, termio is very different from stdio. The ANSI set/get cursor position sequence only works on real stdin/out, once you redirect it won't work. – zhao Jan 14 '19 at 00:35
  • @zhao: ANSI escape sequences work fine when piped, if the helper process writes them to a terminal. The helper acts as a synchronizer; keeping track of I/O to the actual terminal. If you need the original process to see the pipes as a tty, you need to create a pseudoterminal instead of a pipe, with the helper providing essentially a virtual terminal (but with an unix domain datagram socket that can be used for cursor location queries as well). – Nominal Animal Jan 14 '19 at 00:44
  • (That is, the helper is basically doing what `cat` does in `printf '\033[1;31mRED\033[m\n' | cat`, except that it provides an unix domain datagram socket to the `printf` process for cursor position information. The problem is that if the `printf` command uses `isatty()` or similar to modify its behaviour depending on whether it is connected to a terminal or not, is that instead of a pipe, you must use a pseudoterminal master-slave pair, with master being the helper, and slave the `printf` command. Very similar to `ssh -t` or `screen` behaviour; just simpler.) – Nominal Animal Jan 14 '19 at 01:00
  • In any case, **refactoring** is the proper solution. As a first step, you'd need to wrap all input functions so they call your own functions, and all output functions so they call your own functions. Have them always pass the stream they use as the first parameter (ie. `printf(...)` -> `my_fprintf(stdout,...)`). After that point, the modifications needed to get I/O working properly are doable. But, if you are not willing or able to do that, the helper process approach is the other viable one, and almost certainly more work overall. – Nominal Animal Jan 14 '19 at 01:06
0

In a comment, you mention that

Existing code uses readline and ansi escape strings for nice terminal display...

You really should put that information into the question, since it is quite important.

The fact that your code base uses readline severely conditions your possibilities, since readline does not actually work with ncurses. In order to convert the program to use ncurses, you would need to recreate those features of readline which you rely upon. There may be additional libraries which can help with that, but I don't know of any.

On the other hand, ncurses is capable of dividing the screen into non-overlapping regions, and scrolling these regions independently. This is exactly what you need for an application which wants to keep status messages in a status line. Since version 5.7, released about a decade ago, ncurses has primitive threads support (on Linux, anyway), and it is possible to assign different "windows" (screen regions) to different threads. man curs_threads provides some information.

Ncurses also provides easy-to-use interfaces which can replace the use of console control sequences.

So that's probably the long-term solution, but it's going to be a fair amount of work. In the meantime, it is just barely possible to do what you want by using features built in to the readline library. Or at least I was able to write a proof-of-concept which successfully maintained a status line while accepting user input from readline. The import aspect of this solution is that readline is (almost) always active; that is, that there is a thread which is in a hard loop calling readline and passing the buffer read to a processing thread. (With my POC implementation, if the thread calling readline also processes input and the input processing takes a significant amount of time, then the status line will not be updated while input processing takes place.)

The key is the rl_event_hook function, which is called periodically by readline (about 10 times per second) while it is waiting for input. My implementation of rl_event_hook looks like this:

/* This function is never called directly. Enable it by setting:
 *     rl_event_hook = event_hook
 * before the first call to readline.
 */
int event_hook(void) {
  char* msg = NULL;
  pthread_mutex_lock(&status_mutex_);
  if (status_line_) {
    msg = status_line_;
    status_line_ = NULL;
  }
  pthread_mutex_unlock(&status_mutex_);
  if (msg) {
    free(saved_msg_);
    saved_msg_ = msg;  /* Save for redisplay */
    /* Return value of `get_cursor` is a pointer to the `R` in the
     * input buffer, or NULL to indicate that the status reply 
     * couldn't be parsed.
     */
    char cursor_buf[2 + sizeof "x1b[999;999R"];
    char* action = get_cursor(cursor_buf, sizeof cursor_buf - 1);
    if (action) {
      set_cursor(1, 1);
      fputs(msg, stdout);
      clear_eol();
      *action = 'H';
      fputs(cursor_buf, stdout);
    }
  }
  return 0;
}

In order to get a status message to display, you need to lock the mutex and set status_line_ to a dynamically-allocated buffer containing the status line:

/* Set the status message, so that it will (soon) be shown */
void show_status(char* msg) {
  pthread_mutex_lock(&status_mutex_);
  free(status_line_);
  status_line_ = msg;
  pthread_mutex_unlock(&status_mutex_);
}

Since readline does not preserve the status line when a newline character is read (and in certain other cases), and nothing will prevent the screen from scrolling when you send output to it, the above code keeps the current status line in saved_msg_ so that it can be redisplayed when necessary:

/* Show the status message again */
void reshow_status(void) {
  pthread_mutex_lock(&status_line_mutex_);
  msg_ = saved_msg_;
  saved_msg_ = NULL;
  pthread_mutex_unlock(&status_line_mutex_);
}

That's a pretty messy solution, and about the best that can be said for it is that it mostly works, in an imaginary context which might or might not have anything to do with your actual use case. (It's not perfect. There's at least one race condition, although it doesn't actually get triggered in my test code because the only calls to reshow_status are performed in the thread which calls readline, and so the function is only called if there is no possibility for the event hook to run.

It might also be possible for user input to be interlaced with the console's status return, but I think this will be very rare. My implementation of get_cursor does attempt to deal with the possibility of user input characters arriving after the status request has been sent and before the status reply has been received:

  fputs("\x1b[6n", stdout);
  int ch;
  while((ch = getchar()) != 0x1b) rl_stuff_char(ch);

I didn't test this thoroughly so about all I can say is that it seemed to work. (rl_stuff_char inserts a character into a buffer to be used the next time that readline's input loop runs.)

rici
  • 234,347
  • 28
  • 237
  • 341
  • Thanks for the suggestion and detailed explanation! I know it's not easy to marry readline and ncurses, so that's another reason why I wanted to avoid ncurses besides wrapping all prints. Your solution works most of the time, but occasionally the get cursor return does get mingled with user input. It seems that there's no way to prevent the race condition. – zhao Jan 15 '19 at 01:40
  • @zhao: that's not the race condition; the race condition is free()ing `saved_msg_` without having the lock held. It's actually pretty simple to fix, and I guess I should do that. Intermingled input could be avoided by using a pseudo-tty because that would let you actually shut down user input for the few microseconds it takes for the console to issue the status reply. I also considered filtering out characters which would not be produced by the status reply but in the end I settled for abandoning the parsing attempt if it got an unexpected character.... – rici Jan 15 '19 at 02:06
  • ... that should make intermingling pretty visible since the trailing characters would show up as though I'd typed them. But I haven't found a way to trigger that. Probably my environment is different from yours. In the end, the reliable solution will be ripping it all out and replacing it all with ncurses. It might not be as much work as you think it is :-) – rici Jan 15 '19 at 02:07
  • How can I disable user input? As stated in the description, I tried CREAD, TCIOFF but they didn't work. In fact, if I can disable user input then can I just do that in the callback without using rl_event_hook. – zhao Jan 16 '19 at 01:50
  • stop forwarding is not the same as disabling input. When you stop forwarding, user input still gets into stdin, possibly mixing with get cursor response. I noticed that get cursor response is not atomic, so stdin may contain something like "\033[12a;3R" where 'a' is user input and can occur anywhere. – zhao Jan 16 '19 at 06:13
  • @zhao: Yeah, you're right. You're working with a terminal emulator so the keystrokes are being delivered directly as X11 events, and I don't think there is a way to intercept them. – rici Jan 17 '19 at 18:02
  • @zhao: Having said that, and thinking about the issue more, it seems to me that if you have a terminal emulator which does intermingle status responses and keyboard input, that you should report that as a bug. It shouldn't do that, and the terminal emulator's whose code I have handy don't. It makes status reponses unworkable; the same would arise from a terminal emulator which intermingled new keystrokes with escape sequences or mouse status reports. – rici Jan 18 '19 at 15:26