racket / zuo

A tiny Racket for scripting
Other
263 stars 27 forks source link

Proposal for adding non-blocking read for Windows #11

Closed FrankRuben closed 1 year ago

FrankRuben commented 1 year ago

I'm not sure whether that really helps and whether that diff below is the best way to bring that topic forward, but I hacked together Windows support for non-blocking (fd-read ... 'avail).

The code is all but nice - and that is only partly because the error handling is rather unwieldy - but it seems to work in the sense that (fd-read ... 'avail) now returns "" if no input is available or a string with the next char one available and whereas the other values for (fd-read ... amount) to still behave as they did before.

The changed code is behind cpp-define FR_ADDED_WINCONSOLE, so it should be simple to test with and without that change.

If there is another way to help, please tell me so.

diff --git a/racket/src/zuo/zuo.c b/racket/src/zuo/zuo.c
index afec2f86de..00db573b75 100644
--- a/racket/src/zuo/zuo.c
+++ b/racket/src/zuo/zuo.c
@@ -4146,9 +4146,103 @@ static zuo_t *zuo_fd_handle(zuo_raw_handle_t handle, zuo_handle_status_t status,
   return h;
 }

+#ifdef FR_ADDED_WINCONSOLE
+
+static int set_win_console(const BOOL enable) {
+  static DWORD save_input_mode = 0;
+  static DWORD save_output_mode = 0;
+  const DWORD enabled_input_mode = ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_PROCESSED_INPUT;
+  const DWORD enabled_output_mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING | ENABLE_PROCESSED_OUTPUT;
+
+  HANDLE h_stdin = GetStdHandle(STD_INPUT_HANDLE);
+  if (h_stdin == INVALID_HANDLE_VALUE)
+    return GetLastError();
+
+  int rc = 0;
+  DWORD input_mode = 0;
+  if (enable) {
+    save_input_mode = save_output_mode = 0;
+    DWORD temp_input_mode;
+    rc = GetConsoleMode(h_stdin, &temp_input_mode);
+    if (!rc)
+      goto error;
+    save_input_mode = temp_input_mode;
+    input_mode = enabled_input_mode;
+  } else {
+    input_mode = save_input_mode;
+    save_input_mode = 0;
+  }
+  rc = SetConsoleMode(h_stdin, input_mode);
+  if (!rc)
+    goto error;
+
+  HANDLE h_stdout = GetStdHandle(STD_OUTPUT_HANDLE);
+  if (h_stdout == INVALID_HANDLE_VALUE)
+    goto error;
+
+  DWORD output_mode = 0;
+  rc = GetConsoleMode(h_stdout, &output_mode);
+  if (!rc)
+    goto error;
+  if (enable) {
+    save_output_mode = output_mode;
+    output_mode |= enabled_output_mask;
+  }
+  else {
+    output_mode = save_output_mode;
+    save_output_mode = 0;
+  }
+  rc = SetConsoleMode(h_stdout, output_mode);
+  if (!rc)
+    goto error;
+
+  return 0;
+
+error:
+  if (save_input_mode != 0x0000)
+    SetConsoleMode(h_stdin, save_input_mode);
+  if (h_stdout != INVALID_HANDLE_VALUE && save_output_mode != 0x0000)
+    SetConsoleMode(h_stdout, save_output_mode);
+  return GetLastError();
+}
+
+static int read_avail(const int sz, char buf[sz], int *amount_in_chars) {
+  *amount_in_chars = 0;
+
+  HANDLE h_stdin = GetStdHandle(STD_INPUT_HANDLE);
+  if (h_stdin == INVALID_HANDLE_VALUE)
+    return GetLastError();
+
+  INPUT_RECORD ir_in_buf[sz];
+  DWORD amount_in_records;
+  int rc = ReadConsoleInput(h_stdin, ir_in_buf, sz, &amount_in_records);
+  if (rc == 0)
+    return GetLastError();
+
+  if (amount_in_records > 0) {
+    int i_buf = 0;
+    for (int i_event = 0; i_event < amount_in_records; i_event++) {
+      if (ir_in_buf[i_event].EventType == KEY_EVENT) {
+        if (ir_in_buf[i_event].Event.KeyEvent.bKeyDown) {
+          const char ch = ir_in_buf[i_event].Event.KeyEvent.uChar.AsciiChar;
+          if (ch != '\0') {
+            buf[i_buf++] = ch;
+          }
+          // else: skip any other character input
+        } // else: skip key-up events
+      }   // else: skip any other events
+    }
+    *amount_in_chars = i_buf;
+  }
+  return 0;
+}
+
+#endif
+
 static zuo_t *zuo_drain(zuo_raw_handle_t fd, zuo_int_t amount) {
   /* amount as -1 => read until EOF
-     amount as -2 => non-blocking read on Unix */
+     amount as -2 => non-blocking read both on Unix or Windows */
+  const char *who = "zuo_drain";
   zuo_t *s;
   zuo_int_t sz = 256, offset = 0;

@@ -4187,6 +4281,40 @@ static zuo_t *zuo_drain(zuo_raw_handle_t fd, zuo_int_t amount) {
 #endif
 #ifdef ZUO_WINDOWS
     {
+#ifdef FR_ADDED_WINCONSOLE
+      int nonblock = amount == -2;
+      if (nonblock) {
+        int rc = set_win_console(TRUE);
+        if (rc)
+          zuo_fail1w(who, "cannot change console mode", zuo_integer((zuo_int_t) rc));
+
+        char read_avail_buf[sz];
+        int read_avail_amount = 0;
+        int read_avail_last_error = read_avail(sz, read_avail_buf, &read_avail_amount);
+        set_win_console(FALSE);
+
+        if (read_avail_last_error == ERROR_BROKEN_PIPE)
+          amount = got = 0;
+        else if (read_avail_last_error != 0)
+          got = -1;
+        else if (read_avail_amount == 0) {
+          amount = got = 0;
+        } else {
+          // Note: read_avail_amount, read_avail_buf and memcpy do all handle single bytes.
+          memcpy(ZUO_STRING_PTR(s), read_avail_buf, read_avail_amount);
+          amount = got = read_avail_amount;
+        }
+      } else {
+        DWORD dgot;
+        if (!ReadFile(fd, ZUO_STRING_PTR(s) + offset, amt, &dgot, NULL)) {
+          if (GetLastError() == ERROR_BROKEN_PIPE)
+            got = 0;
+          else
+            got = -1;
+        } else
+          got = dgot;
+      }
+#else
       DWORD dgot;
       if (!ReadFile(fd, ZUO_STRING_PTR(s) + offset, amt, &dgot, NULL)) {
         if (GetLastError() == ERROR_BROKEN_PIPE)
@@ -4195,6 +4323,7 @@ static zuo_t *zuo_drain(zuo_raw_handle_t fd, zuo_int_t amount) {
           got = -1;
       } else
         got = dgot;
+#endif
     }
 #endif

@@ -4274,7 +4403,7 @@ static zuo_raw_handle_t zuo_fd_open_input_handle(zuo_t *path, zuo_t *options) {
   zuo_raw_handle_t fd;

   if (options == z.o_undefined) options = z.o_empty_hash;
-  
+
   if (zuo_is_path_string(path)) {
     check_hash(who, options);
     check_options_consumed(who, options);
@@ -4430,7 +4559,7 @@ static zuo_t *zuo_fd_open_output(zuo_t *path, zuo_t *options) {
       zuo_fail_arg(who, "path string, 'stdout, 'stderr, or nonnegative integer", path);
     check_hash(who, options);
     check_options_consumed(who, options);
-#ifdef ZUO_UNIX    
+#ifdef ZUO_UNIX
     fd = zuo_get_std_handle((zuo_raw_handle_t)ZUO_INT_I(path));
     return zuo_handle(fd, zuo_handle_open_fd_out_status);
 #endif
@@ -4496,7 +4625,11 @@ static zuo_t *zuo_fd_read(zuo_t *fd_h, zuo_t *amount) {
       amt = -2;
 #endif
 #ifdef ZUO_WINDOWS
+#ifdef FR_ADDED_WINCONSOLE
+      amt = -2;
+#else
       zuo_fail1w(who, "non-blocking reads are not supported for file descriptor", fd_h);
+#endif
 #endif
     } else if ((amount->tag == zuo_integer_tag)
              && (ZUO_INT_I(amount) >= 0))
mflatt commented 1 year ago

Thanks for this suggestion! I will have to look more at it, and I think it would be important to support files and pipes as well as the console. But using ReadConsoleInput directly looks promising; it's not something I've tried before, and it seems to avoid problems that I've run into with other approaches for non-blocking input from the console.

mflatt commented 1 year ago

After trying out this direction, I think it's probably not a good idea. This approach is something like stty raw -echo in that it provides raw keys and doesn't echo them as a user types. To make it behave something like Unix input, we'd have to do something like implementing a command-line editor on Windows.

In contrast, it's easy to add support for 'avail mode for input streams on Windows other than consoles. We can make 'avail mode support pipes, including input piped from another process. And since Zuo is pretty good at running processes, that gives us an alternative solution: a subprocess can read from stdin (in blocking mode) one byte at a time and echo to its stdout, thus converting console input to pipe input.

;; bounce.zuo
#lang zuo

(let loop ()
   (define s (fd-read (fd-open-input 'stdin) 1))
   (unless (eq? s eof)
     (display s)
     (loop)))
;; main.zuo
#lang zuo

(define p (process (hash-ref (runtime-env) 'exe)
           (at-source "bounce.zuo")
           (hash 'stdout 'pipe)))

;; should burn a CPU as it busy-waits, and won't always
;; get a whole input line at once:
(let loop ()
  (define s (fd-read (hash-ref p 'stdout) 'avail))
  (unless (eq? s eof)
    (unless (equal? s "")
      (alert s))
    (loop)))
FrankRuben commented 1 year ago

Disclaimer: I'm not an expert on this - I just was so fascinated by Zuo's quick start time that I started to fiddle around with it for simple and fast standalone TUI-executables (yes, I've read that it is not meant for that...) and got stuck on the non-blocking user-input for Windows - and tried to use that given API, that I used elsewhere before.

So my main purpose was to support user-interaction, and the thought was to better have non-blocking console-input for Windows than to not having any non-blocking input for Windows at all, and I didn't even consider pipes or files.

After trying out this direction, I think it's probably not a good idea. This approach is something like stty raw -echo in that it provides raw keys and doesn't echo them as a user types.

There are various flags for the SetConsoleMode API, but the echo feature seems to be only allowed in combination with a line-input regime, which is exactly what I wanted to avoid.

To make it behave something like Unix input, we'd have to do something like implementing a command-line editor on Windows.

If I understand you correctly here, the restriction mentioned above that one would need a more sophisticated input layer to allow both non-blocking input and echoing - and without testing various console-flag combinations I would expect the same.

...alternative solution...

I need to play around with this, it's yet a bit above my current Zuo expert level :).

Anyway: thanks for considering this request, thanks for Zuo and thanks for the tremendous work on Racket.