Commit 0593d07c for guacamole.apache.org

commit 0593d07c406d1b1aea9c3658271024ae89355207
Author: Ciro Iriarte <ciro.iriarte@gmail.com>
Date:   Wed Jul 15 17:01:00 2026 -0300

    GUACAMOLE-2301: Add serial reconnect, line-ending, echo, control-line, and diagnostics support

    Implements the reviewed serial console feature gaps on top of the initial
    serial protocol plugin:

    - Auto-reconnect on drop (new "auto-reconnect", default true): the terminal and
      scrollback are kept alive across reconnects; a single stream object persists
      for the session and its fd/telnet context is swapped under write_lock by
      guac_serial_stream_reopen(), so the input thread never touches a stale fd.
    - Line-ending translation (new "line-ending": cr|lf|crlf, default cr) applied to
      typed Enter and pasted text via a small cross-buffer state machine.
    - Distinct contention/error logging: EBUSY/EACCES/EPERM and TIOCEXCL-busy on
      local devices, ECONNREFUSED/ECONNRESET on network transports.
    - Local echo (new "local-echo", default false).
    - Structured connect/disconnect reason logging (EOF, EIO, reset, hangup).
    - Hangup-on-close (new "hangup-on-close" -> HUPCL) and DTR/RTS control-line
      toggles via the serial-control pipe (dtr-on/off, rts-on/off), using
      TIOCMBIS/TIOCMBIC locally and RFC2217 SET-CONTROL (DTR 8/9, RTS 11/12).
    - Connect status banner with an "8N1"-style summary and press-Enter hint.
    - Interruptible paste-delay pacing so a long paste cannot stall teardown.

    The device allowlist is retained in settings and re-validated (re-realpath) on
    every local (re)open.

diff --git a/src/protocols/serial/client.c b/src/protocols/serial/client.c
index 4969bb89..d441fc60 100644
--- a/src/protocols/serial/client.c
+++ b/src/protocols/serial/client.c
@@ -101,13 +101,13 @@ int guac_serial_client_free_handler(guac_client* client) {

     guac_serial_client* serial_client = (guac_serial_client*) client->data;

-    /* Close the serial stream's file descriptor, unblocking the client
-     * thread's read loop. The descriptor is cleared so it is not closed a
-     * second time when the stream is later freed. */
-    if (serial_client->stream != NULL && serial_client->stream->fd != -1) {
-        close(serial_client->stream->fd);
-        serial_client->stream->fd = -1;
-    }
+    /* Signal shutdown so the client thread's read loop, reconnect waits, and
+     * auto-reconnect loop all observe the stopped state and exit promptly. The
+     * stream's own file descriptor is not closed here (it is swapped under a
+     * lock during reconnects); the worker unblocks via its bounded poll timeout
+     * and the descriptor is closed when the stream is freed below, after the
+     * worker has been joined. */
+    guac_client_stop(client);

     /* Stop the terminal — closing its input pipe to unblock the input thread —
      * WITHOUT freeing it yet. The client thread may still be writing serial
diff --git a/src/protocols/serial/local.c b/src/protocols/serial/local.c
index d4b01a9b..3eb9b2c4 100644
--- a/src/protocols/serial/local.c
+++ b/src/protocols/serial/local.c
@@ -36,30 +36,67 @@
 int guac_serial_local_open(guac_serial_stream* stream, guac_client* client,
         guac_serial_settings* settings) {

+    /* Re-validate the device against the allowlist on every (re)open, guarding
+     * against a symlink being repointed at a disallowed device between
+     * reconnects */
+    if (settings->allowed_devices != NULL && settings->allowed_devices[0] != '\0'
+            && !guac_serial_device_permitted(settings->device,
+                    settings->allowed_devices)) {
+        guac_client_log(client, GUAC_LOG_ERROR, "Serial device \"%s\" is not "
+                "permitted by the allowed-devices list, or is not currently "
+                "available.", settings->device);
+        stream->open_status = GUAC_PROTOCOL_STATUS_CLIENT_FORBIDDEN;
+        return -1;
+    }
+
     /* Open device non-blocking so open() does not wait for carrier detect */
     int fd = open(settings->device, O_RDWR | O_NOCTTY | O_NONBLOCK);
     if (fd < 0) {
-        int status = (errno == ENOENT)
-                ? GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND
-                : GUAC_PROTOCOL_STATUS_SERVER_ERROR;
-        guac_client_abort(client, status,
-                "Unable to open serial device \"%s\": %s",
-                settings->device, strerror(errno));
+
+        /* Distinguish contention from other failures */
+        if (errno == EBUSY || errno == EACCES || errno == EPERM) {
+            guac_client_log(client, GUAC_LOG_ERROR, "Serial device \"%s\" is "
+                    "busy or exclusively locked by another process: %s",
+                    settings->device, strerror(errno));
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+        }
+        else if (errno == ENOENT) {
+            guac_client_log(client, GUAC_LOG_ERROR, "Serial device \"%s\" not "
+                    "found: %s", settings->device, strerror(errno));
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND;
+        }
+        else {
+            guac_client_log(client, GUAC_LOG_ERROR, "Unable to open serial "
+                    "device \"%s\": %s", settings->device, strerror(errno));
+            stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
+        }
+
         return -1;
     }

-    /* Request exclusive access; warn but continue if unsupported */
-    if (ioctl(fd, TIOCEXCL) != 0)
+    /* Request exclusive access. A busy result means another process holds the
+     * device exclusively; treat that as a distinct contention failure. Other
+     * failures are non-fatal (the device may simply not support it). */
+    if (ioctl(fd, TIOCEXCL) != 0) {
+        if (errno == EBUSY) {
+            guac_client_log(client, GUAC_LOG_ERROR, "Serial device \"%s\" is "
+                    "busy or exclusively locked by another process.",
+                    settings->device);
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+            close(fd);
+            return -1;
+        }
         guac_client_log(client, GUAC_LOG_WARNING, "Unable to obtain exclusive "
                 "access to serial device \"%s\": %s", settings->device,
                 strerror(errno));
+    }

     /* Read current line settings */
     struct termios tio;
     if (tcgetattr(fd, &tio) != 0) {
-        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
-                "Unable to read attributes of serial device \"%s\": %s",
-                settings->device, strerror(errno));
+        guac_client_log(client, GUAC_LOG_ERROR, "Unable to read attributes of "
+                "serial device \"%s\": %s", settings->device, strerror(errno));
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
         close(fd);
         return -1;
     }
@@ -159,15 +196,22 @@ int guac_serial_local_open(guac_serial_stream* stream, guac_client* client,

     }

+    /* Lower the modem control lines on close if requested (resets many
+     * attached devices), otherwise leave them raised */
+    if (settings->hangup_on_close)
+        tio.c_cflag |= HUPCL;
+    else
+        tio.c_cflag &= ~HUPCL;
+
     /* Block for at least one byte per read, with no inter-byte timeout */
     tio.c_cc[VMIN] = 1;
     tio.c_cc[VTIME] = 0;

     /* Apply configuration immediately and discard any pending I/O */
     if (tcsetattr(fd, TCSANOW, &tio) != 0) {
-        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
-                "Unable to configure serial device \"%s\": %s",
-                settings->device, strerror(errno));
+        guac_client_log(client, GUAC_LOG_ERROR, "Unable to configure serial "
+                "device \"%s\": %s", settings->device, strerror(errno));
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
         close(fd);
         return -1;
     }
diff --git a/src/protocols/serial/pipe.c b/src/protocols/serial/pipe.c
index 8d8171d7..0fd59587 100644
--- a/src/protocols/serial/pipe.c
+++ b/src/protocols/serial/pipe.c
@@ -67,12 +67,27 @@ static void guac_serial_control_dispatch(guac_user* user, const char* token) {

     guac_client* client = user->client;
     guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_serial_stream* stream = serial_client->stream;
+
+    /* All commands operate on an established stream */
+    if (stream == NULL)
+        return;

     /* Send a serial break */
-    if (strcmp(token, "break") == 0) {
-        if (serial_client->stream != NULL)
-            guac_serial_stream_send_break(serial_client->stream);
-    }
+    if (strcmp(token, "break") == 0)
+        guac_serial_stream_send_break(stream);
+
+    /* Toggle the DTR modem control line */
+    else if (strcmp(token, "dtr-on") == 0)
+        guac_serial_stream_set_line(stream, GUAC_SERIAL_CONTROL_LINE_DTR, true);
+    else if (strcmp(token, "dtr-off") == 0)
+        guac_serial_stream_set_line(stream, GUAC_SERIAL_CONTROL_LINE_DTR, false);
+
+    /* Toggle the RTS modem control line */
+    else if (strcmp(token, "rts-on") == 0)
+        guac_serial_stream_set_line(stream, GUAC_SERIAL_CONTROL_LINE_RTS, true);
+    else if (strcmp(token, "rts-off") == 0)
+        guac_serial_stream_set_line(stream, GUAC_SERIAL_CONTROL_LINE_RTS, false);

     /* Ignore unrecognized commands */
     else
diff --git a/src/protocols/serial/rfc2217.c b/src/protocols/serial/rfc2217.c
index 48502df5..e3ba18d9 100644
--- a/src/protocols/serial/rfc2217.c
+++ b/src/protocols/serial/rfc2217.c
@@ -35,9 +35,11 @@
 #include <guacamole/tcp.h>
 #include <libtelnet.h>

+#include <errno.h>
 #include <netinet/in.h>
 #include <netinet/tcp.h>
 #include <stdint.h>
+#include <string.h>
 #include <unistd.h>

 /**
@@ -241,9 +243,30 @@ int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
     int fd = guac_tcp_connect(settings->hostname, settings->port,
             settings->timeout);
     if (fd < 0) {
-        guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND,
-                "Unable to connect to serial server \"%s\" port \"%s\".",
-                settings->hostname, settings->port);
+
+        int err = errno;
+
+        /* Distinguish an actively-refused or reset endpoint from other
+         * failures. The client is not aborted here so the caller may retry. */
+        if (err == ECONNREFUSED) {
+            guac_client_log(client, GUAC_LOG_ERROR, "ser2net endpoint "
+                    "\"%s:%s\" refused the connection: %s", settings->hostname,
+                    settings->port, strerror(err));
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+        }
+        else if (err == ECONNRESET) {
+            guac_client_log(client, GUAC_LOG_ERROR, "Connection to ser2net "
+                    "endpoint \"%s:%s\" was reset: %s", settings->hostname,
+                    settings->port, strerror(err));
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+        }
+        else {
+            guac_client_log(client, GUAC_LOG_ERROR, "Unable to connect to "
+                    "serial server \"%s\" port \"%s\".", settings->hostname,
+                    settings->port);
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND;
+        }
+
         return -1;
     }

@@ -260,8 +283,9 @@ int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
     telnet_t* telnet = telnet_init(rfc2217_options,
             guac_serial_rfc2217_event_handler, 0, stream);
     if (telnet == NULL) {
-        guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
+        guac_client_log(client, GUAC_LOG_ERROR,
                 "RFC2217 client allocation failed.");
+        stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
         close(fd);
         stream->fd = -1;
         return -1;
@@ -309,6 +333,11 @@ int guac_serial_rfc2217_send_break(guac_serial_stream* stream) {

 }

+void guac_serial_rfc2217_set_control(guac_serial_stream* stream, int value) {
+    guac_serial_rfc2217_send_byte((telnet_t*) stream->telnet,
+            GUAC_SERIAL_RFC2217_SET_CONTROL, (unsigned char) value);
+}
+
 void guac_serial_rfc2217_free(guac_serial_stream* stream) {
     if (stream->telnet != NULL) {
         telnet_free((telnet_t*) stream->telnet);
@@ -321,12 +350,11 @@ void guac_serial_rfc2217_free(guac_serial_stream* stream) {
 int guac_serial_rfc2217_open(guac_serial_stream* stream, guac_client* client,
         guac_serial_settings* settings) {

-    (void) stream;
     (void) settings;

-    guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
-            "RFC2217 support was not compiled in "
-            "(rebuild guacamole-server with libtelnet).");
+    guac_client_log(client, GUAC_LOG_ERROR, "RFC2217 support was not compiled "
+            "in (rebuild guacamole-server with libtelnet).");
+    stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
     return -1;

 }
@@ -353,6 +381,12 @@ int guac_serial_rfc2217_send_break(guac_serial_stream* stream) {
     return 0;
 }

+void guac_serial_rfc2217_set_control(guac_serial_stream* stream, int value) {
+    /* RFC2217 unavailable; a stream with this backend is never created */
+    (void) stream;
+    (void) value;
+}
+
 void guac_serial_rfc2217_free(guac_serial_stream* stream) {
     /* No libtelnet session can exist when RFC2217 is not compiled in */
     (void) stream;
diff --git a/src/protocols/serial/rfc2217.h b/src/protocols/serial/rfc2217.h
index 971f6a22..adb44546 100644
--- a/src/protocols/serial/rfc2217.h
+++ b/src/protocols/serial/rfc2217.h
@@ -107,6 +107,21 @@ void guac_serial_rfc2217_recv(guac_serial_stream* stream, const char* buffer,
  */
 int guac_serial_rfc2217_send_break(guac_serial_stream* stream);

+/**
+ * Sends a COM-PORT-OPTION SET-CONTROL subnegotiation with the given control
+ * value to the remote end of an RFC2217 connection. Used to toggle the modem
+ * control lines (e.g. DTR ON=8, DTR OFF=9, RTS ON=11, RTS OFF=12 per RFC 2217).
+ * This must only be called on a stream whose backend is
+ * GUAC_SERIAL_BACKEND_RFC2217.
+ *
+ * @param stream
+ *     The RFC2217 serial stream on which to send the SET-CONTROL command.
+ *
+ * @param value
+ *     The RFC 2217 SET-CONTROL value to send.
+ */
+void guac_serial_rfc2217_set_control(guac_serial_stream* stream, int value);
+
 /**
  * Frees the libtelnet session associated with the given stream, if any. Safe
  * to call regardless of backend; has no effect if no session is present.
diff --git a/src/protocols/serial/serial.c b/src/protocols/serial/serial.c
index 93c9c38c..ff8f1b93 100644
--- a/src/protocols/serial/serial.c
+++ b/src/protocols/serial/serial.c
@@ -33,16 +33,209 @@
 #include <guacamole/protocol.h>
 #include <guacamole/recording.h>

+#include <errno.h>
 #include <poll.h>
 #include <pthread.h>
+#include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <unistd.h>

+/**
+ * The reason a serial read loop exited, used to produce a concise, accurate
+ * disconnect message.
+ */
+typedef enum guac_serial_disconnect_reason {
+
+    /**
+     * The remote/device closed the connection cleanly (read() returned 0).
+     */
+    GUAC_SERIAL_DISCONNECT_EOF,
+
+    /**
+     * A device I/O error occurred (errno EIO), typically an unplugged device.
+     */
+    GUAC_SERIAL_DISCONNECT_IO_ERROR,
+
+    /**
+     * The connection was reset by the peer (errno ECONNRESET).
+     */
+    GUAC_SERIAL_DISCONNECT_RESET,
+
+    /**
+     * The descriptor hung up or reported an error condition (POLLHUP/POLLERR).
+     */
+    GUAC_SERIAL_DISCONNECT_HANGUP,
+
+    /**
+     * An otherwise-unclassified read or poll error occurred.
+     */
+    GUAC_SERIAL_DISCONNECT_ERROR,
+
+    /**
+     * The client is shutting down; the loop exited for teardown, not a fault.
+     */
+    GUAC_SERIAL_DISCONNECT_STOPPING
+
+} guac_serial_disconnect_reason;
+
+/**
+ * Returns the letter (N/E/O/M/S) representing the given parity scheme, as used
+ * in the "8N1"-style line settings summary.
+ *
+ * @param parity
+ *     The parity scheme.
+ *
+ * @return
+ *     The single-character parity letter.
+ */
+static char guac_serial_parity_letter(guac_serial_parity parity) {
+    switch (parity) {
+        case GUAC_SERIAL_PARITY_ODD:   return 'O';
+        case GUAC_SERIAL_PARITY_EVEN:  return 'E';
+        case GUAC_SERIAL_PARITY_MARK:  return 'M';
+        case GUAC_SERIAL_PARITY_SPACE: return 'S';
+        default:                       return 'N';
+    }
+}
+
+/**
+ * Writes a human-readable description of the connection's endpoint (the local
+ * device path, or "host:port" for network connections) into the given buffer.
+ *
+ * @param settings
+ *     The connection settings.
+ *
+ * @param buffer
+ *     The buffer to write the endpoint description into.
+ *
+ * @param size
+ *     The size of the buffer, in bytes.
+ */
+static void guac_serial_endpoint(guac_serial_settings* settings, char* buffer,
+        int size) {
+    if (settings->type == GUAC_SERIAL_TYPE_LOCAL)
+        snprintf(buffer, size, "%s", settings->device);
+    else
+        snprintf(buffer, size, "%s:%s", settings->hostname, settings->port);
+}
+
+/**
+ * Writes a concise, dim, bracketed status banner to the terminal on its own
+ * line, so a blank screen is diagnosable.
+ *
+ * @param term
+ *     The terminal to write the banner to.
+ *
+ * @param text
+ *     The banner text (without surrounding brackets or styling).
+ */
+static void guac_serial_write_banner(guac_terminal* term, const char* text) {
+    char buffer[512];
+    int length = snprintf(buffer, sizeof(buffer),
+            "\r\n\x1b[2m[serial: %s]\x1b[0m\r\n", text);
+    if (length > 0) {
+        if (length >= (int) sizeof(buffer))
+            length = sizeof(buffer) - 1;
+        guac_terminal_write(term, buffer, length);
+    }
+}
+
+/**
+ * Returns the wire terminator bytes and length for the given line ending.
+ *
+ * @param line_ending
+ *     The configured line ending.
+ *
+ * @param terminator
+ *     Set to the terminator byte sequence.
+ *
+ * @param length
+ *     Set to the length of the terminator, in bytes.
+ */
+static void guac_serial_line_terminator(guac_serial_line_ending line_ending,
+        const char** terminator, int* length) {
+    switch (line_ending) {
+        case GUAC_SERIAL_LINE_ENDING_LF:   *terminator = "\n";   *length = 1; break;
+        case GUAC_SERIAL_LINE_ENDING_CRLF: *terminator = "\r\n"; *length = 2; break;
+        default:                           *terminator = "\r";   *length = 1; break;
+    }
+}
+
+/**
+ * Translates outgoing line endings in the given input buffer to the configured
+ * terminator, collapsing any of "\r", "\n", or "\r\n" to a single terminator.
+ * A small cross-call state machine (saw_cr) ensures a "\r\n" pair split across
+ * buffers, or within one buffer, produces exactly one terminator. Non-newline
+ * bytes are passed through unchanged.
+ *
+ * @param line_ending
+ *     The configured line ending.
+ *
+ * @param in
+ *     The input buffer.
+ *
+ * @param in_length
+ *     The number of bytes in the input buffer.
+ *
+ * @param out
+ *     The output buffer, which must have capacity for at least twice
+ *     in_length bytes.
+ *
+ * @param saw_cr
+ *     Pointer to the carry state: non-zero if the previous byte processed was
+ *     a carriage return whose terminator was already emitted. Must be
+ *     initialized to zero before the first call and preserved across calls.
+ *
+ * @return
+ *     The number of bytes written to the output buffer.
+ */
+static int guac_serial_translate_line_ending(guac_serial_line_ending line_ending,
+        const char* in, int in_length, char* out, int* saw_cr) {
+
+    const char* terminator;
+    int terminator_length;
+    guac_serial_line_terminator(line_ending, &terminator, &terminator_length);
+
+    int out_length = 0;
+    for (int i = 0; i < in_length; i++) {
+
+        char c = in[i];
+
+        /* A carriage return always emits one terminator */
+        if (c == '\r') {
+            memcpy(out + out_length, terminator, terminator_length);
+            out_length += terminator_length;
+            *saw_cr = 1;
+        }
+
+        /* A line feed emits a terminator unless it completes a "\r\n" pair */
+        else if (c == '\n') {
+            if (!*saw_cr) {
+                memcpy(out + out_length, terminator, terminator_length);
+                out_length += terminator_length;
+            }
+            *saw_cr = 0;
+        }
+
+        /* All other bytes pass through unchanged */
+        else {
+            out[out_length++] = c;
+            *saw_cr = 0;
+        }
+
+    }
+
+    return out_length;
+
+}
+
 /**
  * Input thread, started by the main serial client thread. This thread
- * continuously reads from the terminal's STDIN and transfers all read data to
- * the serial line.
+ * continuously reads from the terminal's STDIN, translates outgoing line
+ * endings, optionally echoes input locally, and transfers the data to the
+ * serial stream. It survives reconnects (the stream drops input while the
+ * transport is down) and exits only when STDIN closes at teardown.
  *
  * @param data
  *     The current guac_client instance.
@@ -58,15 +251,27 @@ static void* guac_serial_input_thread(void* data) {

     guac_client* client = (guac_client*) data;
     guac_serial_client* serial_client = (guac_serial_client*) client->data;
+    guac_serial_settings* settings = serial_client->settings;

     char buffer[8192];
+    char translated[2 * sizeof(buffer)];
+    int saw_cr = 0;
     int bytes_read;

     /* Write all data read from STDIN to the serial stream */
     while ((bytes_read = guac_terminal_read_stdin(serial_client->term, buffer,
             sizeof(buffer))) > 0) {
-        if (guac_serial_stream_write(serial_client->stream, buffer, bytes_read) < 0)
-            break;
+
+        /* Translate outgoing line endings before pacing/writing */
+        int length = guac_serial_translate_line_ending(settings->line_ending,
+                buffer, bytes_read, translated, &saw_cr);
+
+        guac_serial_stream_write(serial_client->stream, translated, length);
+
+        /* Echo the user's typed bytes locally, if enabled */
+        if (settings->local_echo)
+            guac_terminal_write(serial_client->term, buffer, bytes_read);
+
     }

     return NULL;
@@ -74,30 +279,142 @@ static void* guac_serial_input_thread(void* data) {
 }

 /**
- * Waits for data on the given file descriptor for up to one second. The return
- * value is identical to that of poll(): 0 on timeout, < 0 on error, and > 0
- * when the descriptor is readable or has hung up.
+ * Runs the read loop for the currently-open serial stream, writing received
+ * data to the terminal until the connection drops or the client stops.
  *
- * @param fd
- *     The file descriptor to wait for.
+ * @param client
+ *     The guac_client associated with the connection.
+ *
+ * @param serial_client
+ *     The serial client data, whose stream must currently be open.
  *
  * @return
- *     A value greater than zero when data is available or the descriptor has
- *     hung up, zero on timeout, and less than zero on error.
+ *     The reason the loop exited.
  */
-static int guac_serial_wait(int fd) {
+static guac_serial_disconnect_reason guac_serial_run_read_loop(
+        guac_client* client, guac_serial_client* serial_client) {
+
+    char buffer[8192];
+
+    while (client->state == GUAC_CLIENT_RUNNING) {

-    /* Watch for readable data as well as hangup/error conditions */
-    struct pollfd fds[] = {{
-        .fd      = fd,
-        .events  = POLLIN | POLLHUP | POLLERR,
-        .revents = 0,
-    }};
+        int fd = serial_client->stream->fd;

-    int wait_result;
-    GUAC_RETRY_EINTR(wait_result, poll(fds, 1, 1000));
+        /* Wait up to one second for data, hangup, or error */
+        struct pollfd pfd = { .fd = fd, .events = POLLIN, .revents = 0 };
+        int wait_result;
+        GUAC_RETRY_EINTR(wait_result, poll(&pfd, 1, 1000));

-    return wait_result;
+        if (wait_result < 0)
+            return GUAC_SERIAL_DISCONNECT_ERROR;
+
+        /* Nothing to read yet */
+        if (wait_result == 0)
+            continue;
+
+        /* Hangup/error with no pending data ends the connection */
+        if ((pfd.revents & (POLLHUP | POLLERR | POLLNVAL))
+                && !(pfd.revents & POLLIN))
+            return GUAC_SERIAL_DISCONNECT_HANGUP;
+
+        int bytes_read;
+        GUAC_RETRY_EINTR(bytes_read, read(fd, buffer, sizeof(buffer)));
+
+        /* Classify the disconnect */
+        if (bytes_read == 0)
+            return GUAC_SERIAL_DISCONNECT_EOF;
+        if (bytes_read < 0) {
+            if (errno == EIO)
+                return GUAC_SERIAL_DISCONNECT_IO_ERROR;
+            if (errno == ECONNRESET)
+                return GUAC_SERIAL_DISCONNECT_RESET;
+            return GUAC_SERIAL_DISCONNECT_ERROR;
+        }
+
+        /* RFC2217 output must be de-framed; all other transports are raw */
+        if (serial_client->stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
+            guac_serial_rfc2217_recv(serial_client->stream, buffer, bytes_read);
+        else
+            guac_terminal_write(serial_client->term, buffer, bytes_read);
+
+    }
+
+    return GUAC_SERIAL_DISCONNECT_STOPPING;
+
+}
+
+/**
+ * Logs the reason a serial connection dropped, at a level appropriate to
+ * whether it represents a normal end-of-connection or a fault.
+ *
+ * @param client
+ *     The guac_client associated with the connection.
+ *
+ * @param reason
+ *     The reason the connection dropped.
+ */
+static void guac_serial_log_disconnect(guac_client* client,
+        guac_serial_disconnect_reason reason) {
+
+    switch (reason) {
+
+        case GUAC_SERIAL_DISCONNECT_EOF:
+            guac_client_log(client, GUAC_LOG_INFO,
+                    "Serial connection dropped: remote/device closed the "
+                    "connection (EOF).");
+            break;
+
+        case GUAC_SERIAL_DISCONNECT_IO_ERROR:
+            guac_client_log(client, GUAC_LOG_WARNING,
+                    "Serial connection dropped: device I/O error (unplugged?).");
+            break;
+
+        case GUAC_SERIAL_DISCONNECT_RESET:
+            guac_client_log(client, GUAC_LOG_WARNING,
+                    "Serial connection dropped: connection reset.");
+            break;
+
+        case GUAC_SERIAL_DISCONNECT_HANGUP:
+            guac_client_log(client, GUAC_LOG_WARNING,
+                    "Serial connection dropped: hangup.");
+            break;
+
+        case GUAC_SERIAL_DISCONNECT_ERROR:
+            guac_client_log(client, GUAC_LOG_WARNING,
+                    "Serial connection dropped: read error.");
+            break;
+
+        case GUAC_SERIAL_DISCONNECT_STOPPING:
+            /* Normal teardown; nothing to report as a fault */
+            break;
+
+    }
+
+}
+
+/**
+ * Sleeps for the given number of seconds, returning early if the client stops
+ * running. The wait is broken into short slices so that teardown is prompt.
+ *
+ * @param client
+ *     The client whose shutdown state is checked.
+ *
+ * @param seconds
+ *     The number of seconds to wait.
+ *
+ * @return
+ *     Non-zero if the client stopped running during the wait, zero otherwise.
+ */
+static int guac_serial_reconnect_wait(guac_client* client, int seconds) {
+
+    int milliseconds = seconds * 1000;
+    while (milliseconds > 0 && client->state == GUAC_CLIENT_RUNNING) {
+        int slice = milliseconds < 100 ? milliseconds : 100;
+        usleep((useconds_t) slice * 1000);
+        milliseconds -= slice;
+    }
+
+    return client->state != GUAC_CLIENT_RUNNING;

 }

@@ -112,14 +429,21 @@ void* guac_serial_client_thread(void* data) {
     guac_serial_settings* settings = serial_client->settings;

     pthread_t input_thread;
-    char buffer[8192];
-    int wait_result;
+
+    /* Human-readable endpoint and "9600 8N1"-style line settings summary */
+    char endpoint[256];
+    guac_serial_endpoint(settings, endpoint, sizeof(endpoint));
+
+    char line_settings[64];
+    snprintf(line_settings, sizeof(line_settings), "%d %d%c%d",
+            settings->baud_rate, settings->data_bits,
+            guac_serial_parity_letter(settings->parity), settings->stop_bits);

     /* Set process title to the device or hostname reached by this connection */
-    const char* endpoint = settings->type == GUAC_SERIAL_TYPE_LOCAL
+    const char* title_host = settings->type == GUAC_SERIAL_TYPE_LOCAL
             ? settings->device : settings->hostname;
     guac_process_title_set_endpoint(GUAC_SERIAL_PROCESS_TITLE_NAME,
-            NULL, endpoint, NULL);
+            NULL, title_host, NULL);

     /* Set up screen recording, if requested */
     if (settings->recording_path != NULL) {
@@ -171,23 +495,18 @@ void* guac_serial_client_thread(void* data) {
                 settings->typescript_write_existing);
     }

-    /* Open the serial stream via the configured transport */
-    serial_client->stream = guac_serial_stream_open(client, settings);
-    if (serial_client->stream == NULL) {
-        /* Already aborted within guac_serial_stream_open() */
-        return NULL;
-    }
-
-    /* Connection established */
-    guac_client_log(client, GUAC_LOG_INFO, "Serial connection established.");
+    /* Allocate the persistent stream (not yet connected) so it survives
+     * reconnects for the session lifetime */
+    serial_client->stream = guac_serial_stream_alloc(client, settings);

-    /* Allow the terminal to render; there is no login detection */
+    /* Allow the terminal to render; there is no login detection. Started before
+     * the first connect so that (re)connection banners are visible. */
     guac_terminal_start(serial_client->term);

     /* Send current values of exposed arguments to owner only */
     guac_client_for_owner(client, guac_serial_send_current_argv, serial_client);

-    /* Start input thread */
+    /* Start input thread (persists across reconnects) */
     if (pthread_create(&input_thread, NULL, guac_serial_input_thread,
             (void*) client)) {
         guac_client_abort(client, GUAC_PROTOCOL_STATUS_SERVER_ERROR,
@@ -195,26 +514,90 @@ void* guac_serial_client_thread(void* data) {
         return NULL;
     }

-    /* While data available, write to terminal */
-    while ((wait_result = guac_serial_wait(serial_client->stream->fd)) >= 0) {
-
-        /* Resume waiting if no data available */
-        if (wait_result == 0)
-            continue;
+    int connected = 0;
+    int first_attempt = 1;
+    int ever_connected = 0;
+
+    while (client->state == GUAC_CLIENT_RUNNING) {
+
+        /* (Re)connect if not currently connected */
+        if (!connected) {
+
+            /* Pace reconnect attempts, but do not wait before the very first
+             * connect attempt */
+            if (!first_attempt) {
+                if (guac_serial_reconnect_wait(client,
+                        GUAC_SERIAL_RECONNECT_INTERVAL))
+                    break;
+            }
+
+            int result = guac_serial_stream_reopen(serial_client->stream,
+                    client, settings);
+
+            if (result != 0) {
+
+                /* A failed very-first connect with reconnect disabled aborts,
+                 * exactly as before */
+                if (first_attempt && !settings->auto_reconnect) {
+                    guac_client_abort(client, serial_client->stream->open_status,
+                            "Unable to establish serial connection to %s.",
+                            endpoint);
+                    break;
+                }
+
+                /* Announce the wait so a blank screen is diagnosable */
+                if (first_attempt)
+                    guac_serial_write_banner(serial_client->term,
+                            "connecting…");
+
+                first_attempt = 0;
+                continue;
+
+            }
+
+            /* Connected */
+            connected = 1;
+            first_attempt = 0;
+
+            if (ever_connected) {
+                guac_client_log(client, GUAC_LOG_INFO, "Serial connection "
+                        "re-established to %s (%s).", endpoint, line_settings);
+                guac_serial_write_banner(serial_client->term, "reconnected");
+            }
+            else
+                guac_client_log(client, GUAC_LOG_INFO, "Serial connection "
+                        "established to %s (%s).", endpoint, line_settings);
+
+            ever_connected = 1;
+
+            /* Status banner with press-Enter hint (§7) */
+            char banner[384];
+            snprintf(banner, sizeof(banner),
+                    "connected to %s @ %s — press Enter if blank",
+                    endpoint, line_settings);
+            guac_serial_write_banner(serial_client->term, banner);
+
+        }
+
+        /* Run the read loop until the connection drops */
+        guac_serial_disconnect_reason reason =
+                guac_serial_run_read_loop(client, serial_client);
+        connected = 0;
+
+        /* Stop quietly if the client is shutting down */
+        if (reason == GUAC_SERIAL_DISCONNECT_STOPPING
+                || client->state != GUAC_CLIENT_RUNNING)
+            break;

-        int bytes_read;
-        GUAC_RETRY_EINTR(bytes_read,
-                read(serial_client->stream->fd, buffer, sizeof(buffer)));
+        guac_serial_log_disconnect(client, reason);

-        /* EOF, EIO, or hangup indicates the serial line has closed */
-        if (bytes_read <= 0)
+        /* Without auto-reconnect, a drop ends the session */
+        if (!settings->auto_reconnect)
             break;

-        /* RFC2217 output must be de-framed; all other transports are raw */
-        if (serial_client->stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
-            guac_serial_rfc2217_recv(serial_client->stream, buffer, bytes_read);
-        else
-            guac_terminal_write(serial_client->term, buffer, bytes_read);
+        /* Announce reconnection and loop to retry */
+        guac_serial_write_banner(serial_client->term,
+                "connection lost — reconnecting…");

     }

diff --git a/src/protocols/serial/settings.c b/src/protocols/serial/settings.c
index 770ff906..ddb68cc8 100644
--- a/src/protocols/serial/settings.c
+++ b/src/protocols/serial/settings.c
@@ -74,6 +74,10 @@ const char* GUAC_SERIAL_CLIENT_ARGS[] = {
     "recording-include-clipboard",
     "create-recording-path",
     "recording-write-existing",
+    "auto-reconnect",
+    "line-ending",
+    "local-echo",
+    "hangup-on-close",
     NULL
 };

@@ -282,6 +286,30 @@ enum SERIAL_ARGS_IDX {
      */
     IDX_RECORDING_WRITE_EXISTING,

+    /**
+     * Whether the connection should automatically reconnect if the serial line
+     * is dropped. Defaults to true.
+     */
+    IDX_AUTO_RECONNECT,
+
+    /**
+     * The line ending written to the serial line for the operator's outgoing
+     * newlines ("cr", "lf", or "crlf"). Defaults to "cr".
+     */
+    IDX_LINE_ENDING,
+
+    /**
+     * Whether user input should be echoed to the terminal locally. Defaults to
+     * false.
+     */
+    IDX_LOCAL_ECHO,
+
+    /**
+     * Whether the modem control lines should be lowered (HUPCL) when the local
+     * device is closed. Defaults to false.
+     */
+    IDX_HANGUP_ON_CLOSE,
+
     SERIAL_ARGS_COUNT
 };

@@ -383,33 +411,30 @@ static int guac_serial_parse_flow(const char* str, guac_serial_flow_control* flo
 }

 /**
- * Returns whether the given local device is permitted by the given ":"-
- * separated allowlist of device paths or path prefixes. Both the device and
- * each allowlist entry are canonicalized with realpath() before comparison; a
- * device is permitted if its canonical path equals a canonicalized entry or
- * falls beneath one of the canonicalized entries.
- *
- * @param user
- *     The user on whose behalf error messages should be logged.
+ * Parses the given line-ending string into a guac_serial_line_ending value.
  *
- * @param device
- *     The local device path to test.
+ * @param str
+ *     The line-ending string ("cr", "lf", or "crlf").
  *
- * @param allowed
- *     The ":"-separated allowlist of permitted device paths or prefixes.
+ * @param line_ending
+ *     A pointer to the value that should receive the parsed result.
  *
  * @return
- *     true if the device is permitted, false otherwise.
+ *     Zero if the string was successfully parsed, non-zero otherwise.
  */
-static bool guac_serial_device_allowed(guac_user* user, const char* device,
-        const char* allowed) {
+static int guac_serial_parse_line_ending(const char* str,
+        guac_serial_line_ending* line_ending) {
+    if (strcmp(str, "cr") == 0)   { *line_ending = GUAC_SERIAL_LINE_ENDING_CR;   return 0; }
+    if (strcmp(str, "lf") == 0)   { *line_ending = GUAC_SERIAL_LINE_ENDING_LF;   return 0; }
+    if (strcmp(str, "crlf") == 0) { *line_ending = GUAC_SERIAL_LINE_ENDING_CRLF; return 0; }
+    return 1;
+}
+
+bool guac_serial_device_permitted(const char* device, const char* allowed) {

     char real_device[PATH_MAX];
-    if (realpath(device, real_device) == NULL) {
-        guac_user_log(user, GUAC_LOG_ERROR, "Unable to resolve serial device "
-                "\"%s\": %s", device, strerror(errno));
+    if (realpath(device, real_device) == NULL)
         return false;
-    }

     /* Duplicate the list so it may be tokenized in place */
     char* list = guac_strdup(allowed);
@@ -446,11 +471,6 @@ static bool guac_serial_device_allowed(guac_user* user, const char* device,
     }

     guac_mem_free(list);
-
-    if (!permitted)
-        guac_user_log(user, GUAC_LOG_ERROR, "Serial device \"%s\" is not "
-                "permitted by the configured allowed-devices list.", device);
-
     return permitted;

 }
@@ -618,18 +638,51 @@ guac_serial_settings* guac_serial_parse_args(guac_user* user,
     if (settings->paste_delay < 0)
         settings->paste_delay = 0;

-    /* Enforce local device allowlist, if configured */
-    char* allowed = guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS,
-            argv, IDX_ALLOWED_DEVICES, NULL);
+    /* Parse auto-reconnect flag */
+    settings->auto_reconnect =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_AUTO_RECONNECT, true);
+
+    /* Parse line ending */
+    char* line_ending_str = guac_user_parse_args_string(user,
+            GUAC_SERIAL_CLIENT_ARGS, argv, IDX_LINE_ENDING, "cr");
+    if (guac_serial_parse_line_ending(line_ending_str, &settings->line_ending)) {
+        guac_user_log(user, GUAC_LOG_ERROR, "Invalid line-ending \"%s\": must "
+                "be \"cr\", \"lf\", or \"crlf\".", line_ending_str);
+        guac_mem_free(line_ending_str);
+        guac_serial_settings_free(settings);
+        return NULL;
+    }
+    guac_mem_free(line_ending_str);
+
+    /* Parse local echo flag */
+    settings->local_echo =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_LOCAL_ECHO, false);
+
+    /* Parse hangup-on-close flag */
+    settings->hangup_on_close =
+        guac_user_parse_args_boolean(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_HANGUP_ON_CLOSE, false);
+
+    /* Retain the device allowlist so it can be re-checked on every (re)open */
+    settings->allowed_devices =
+        guac_user_parse_args_string(user, GUAC_SERIAL_CLIENT_ARGS, argv,
+                IDX_ALLOWED_DEVICES, NULL);
+
+    /* Enforce local device allowlist at parse time, if configured */
     if (settings->type == GUAC_SERIAL_TYPE_LOCAL
-            && allowed != NULL && allowed[0] != '\0') {
-        if (!guac_serial_device_allowed(user, settings->device, allowed)) {
-            guac_mem_free(allowed);
+            && settings->allowed_devices != NULL
+            && settings->allowed_devices[0] != '\0') {
+        if (!guac_serial_device_permitted(settings->device,
+                settings->allowed_devices)) {
+            guac_user_log(user, GUAC_LOG_ERROR, "Serial device \"%s\" is not "
+                    "permitted by the configured allowed-devices list, or "
+                    "could not be resolved.", settings->device);
             guac_serial_settings_free(settings);
             return NULL;
         }
     }
-    guac_mem_free(allowed);

     /* Read-only mode */
     settings->read_only =
@@ -771,6 +824,7 @@ void guac_serial_settings_free(guac_serial_settings* settings) {

     /* Free transport connection information */
     guac_mem_free(settings->device);
+    guac_mem_free(settings->allowed_devices);
     guac_mem_free(settings->hostname);
     guac_mem_free(settings->port);

diff --git a/src/protocols/serial/settings.h b/src/protocols/serial/settings.h
index 728c8196..65dab32e 100644
--- a/src/protocols/serial/settings.h
+++ b/src/protocols/serial/settings.h
@@ -50,6 +50,12 @@
  */
 #define GUAC_SERIAL_DEFAULT_BREAK_DURATION 500

+/**
+ * The interval, in seconds, between automatic reconnection attempts after the
+ * serial connection is dropped.
+ */
+#define GUAC_SERIAL_RECONNECT_INTERVAL 2
+
 /**
  * The filename to use for the typescript, if not specified.
  */
@@ -118,6 +124,30 @@ typedef enum guac_serial_flow_control {
     GUAC_SERIAL_FLOW_XON_XOFF
 } guac_serial_flow_control;

+/**
+ * The line ending written to the serial line in place of the operator's
+ * outgoing carriage returns/newlines.
+ */
+typedef enum guac_serial_line_ending {
+
+    /**
+     * A single carriage return ("\r"). This is what most network device
+     * consoles (e.g. Cisco) expect, and is the default.
+     */
+    GUAC_SERIAL_LINE_ENDING_CR,
+
+    /**
+     * A single line feed ("\n").
+     */
+    GUAC_SERIAL_LINE_ENDING_LF,
+
+    /**
+     * A carriage return followed by a line feed ("\r\n").
+     */
+    GUAC_SERIAL_LINE_ENDING_CRLF
+
+} guac_serial_line_ending;
+
 /**
  * Settings for the serial connection. The values for this structure are parsed
  * from the arguments given during the Guacamole protocol handshake using the
@@ -142,6 +172,14 @@ typedef struct guac_serial_settings {
      */
     char* device;

+    /**
+     * An optional ":"-separated allowlist of permitted local device paths or
+     * path prefixes, or NULL if no allowlist was configured. Retained so the
+     * device can be re-validated on every (re)open, guarding against a symlink
+     * being repointed at a disallowed device between reconnects.
+     */
+    char* allowed_devices;
+
     /**
      * The hostname of the network serial server to connect to when type is
      * GUAC_SERIAL_TYPE_NETWORK. NULL if not a network connection.
@@ -197,6 +235,30 @@ typedef struct guac_serial_settings {
      */
     int paste_delay;

+    /**
+     * Whether the connection should automatically attempt to reconnect if the
+     * serial line is dropped, keeping the terminal and its scrollback intact.
+     */
+    bool auto_reconnect;
+
+    /**
+     * The line ending written to the serial line in place of the operator's
+     * outgoing carriage returns/newlines.
+     */
+    guac_serial_line_ending line_ending;
+
+    /**
+     * Whether user input should be echoed to the terminal locally. Useful for
+     * devices or bootloaders which do not echo typed characters themselves.
+     */
+    bool local_echo;
+
+    /**
+     * Whether the modem control lines should be lowered (HUPCL) when the local
+     * device is closed, which resets many attached devices.
+     */
+    bool hangup_on_close;
+
     /**
      * Whether this connection is read-only, and user input should be dropped.
      */
@@ -387,6 +449,28 @@ guac_serial_settings* guac_serial_parse_args(guac_user* user,
  */
 speed_t guac_serial_baud_to_speed(int baud_rate);

+/**
+ * Returns whether the given local device is permitted by the given ":"-
+ * separated allowlist of device paths or path prefixes. Both the device and
+ * each allowlist entry are canonicalized with realpath() before comparison; a
+ * device is permitted if its canonical path equals a canonicalized entry or
+ * falls beneath one of the canonicalized entries. If the device cannot be
+ * resolved (e.g. it does not currently exist), it is not permitted.
+ *
+ * This performs no logging so that it may be reused both at parse time and on
+ * every (re)open.
+ *
+ * @param device
+ *     The local device path to test.
+ *
+ * @param allowed
+ *     The ":"-separated allowlist of permitted device paths or prefixes.
+ *
+ * @return
+ *     true if the device is permitted, false otherwise.
+ */
+bool guac_serial_device_permitted(const char* device, const char* allowed);
+
 /**
  * Frees the given guac_serial_settings object, having been previously
  * allocated via guac_serial_parse_args().
diff --git a/src/protocols/serial/stream.c b/src/protocols/serial/stream.c
index 1d189413..cd1c3c0e 100644
--- a/src/protocols/serial/stream.c
+++ b/src/protocols/serial/stream.c
@@ -28,6 +28,7 @@
 #include <guacamole/client.h>
 #include <guacamole/error.h>
 #include <guacamole/mem.h>
+#include <guacamole/protocol.h>

 #include <errno.h>
 #include <string.h>
@@ -35,6 +36,13 @@
 #include <termios.h>
 #include <unistd.h>

+/**
+ * The granularity, in milliseconds, of the interruptible sleep used to pace
+ * byte-by-byte writes. Shorter slices make teardown more responsive during a
+ * long paced paste; longer slices reduce wakeups.
+ */
+#define GUAC_SERIAL_PACING_SLICE 20
+
 /**
  * Writes the entire buffer given to the specified file descriptor, retrying
  * the write automatically if necessary.
@@ -71,7 +79,30 @@ static int guac_serial_stream_write_all(int fd, const char* buffer, int size) {

 }

-guac_serial_stream* guac_serial_stream_open(guac_client* client,
+/**
+ * Sleeps for the given number of milliseconds, returning early if the client
+ * stops running. The wait is broken into short slices so that teardown is not
+ * delayed by a long paced write.
+ *
+ * @param stream
+ *     The serial stream whose client is checked for shutdown.
+ *
+ * @param milliseconds
+ *     The number of milliseconds to sleep.
+ */
+static void guac_serial_stream_interruptible_sleep(guac_serial_stream* stream,
+        int milliseconds) {
+
+    while (milliseconds > 0 && stream->client->state == GUAC_CLIENT_RUNNING) {
+        int slice = milliseconds < GUAC_SERIAL_PACING_SLICE
+                ? milliseconds : GUAC_SERIAL_PACING_SLICE;
+        usleep((useconds_t) slice * 1000);
+        milliseconds -= slice;
+    }
+
+}
+
+guac_serial_stream* guac_serial_stream_alloc(guac_client* client,
         guac_serial_settings* settings) {

     guac_serial_stream* stream = guac_mem_zalloc(sizeof(guac_serial_stream));
@@ -80,9 +111,28 @@ guac_serial_stream* guac_serial_stream_open(guac_client* client,
     stream->telnet = NULL;
     stream->paste_delay = settings->paste_delay;
     stream->break_duration = settings->break_duration;
+    stream->open_status = GUAC_PROTOCOL_STATUS_SERVER_ERROR;
     pthread_mutex_init(&stream->write_lock, NULL);

-    /* Dispatch to the appropriate backend */
+    return stream;
+
+}
+
+int guac_serial_stream_reopen(guac_serial_stream* stream, guac_client* client,
+        guac_serial_settings* settings) {
+
+    pthread_mutex_lock(&stream->write_lock);
+
+    /* Tear down any existing transport so the fd/telnet are never stale */
+    guac_serial_rfc2217_free(stream);
+    if (stream->fd != -1) {
+        close(stream->fd);
+        stream->fd = -1;
+    }
+
+    /* Dispatch to the appropriate backend. Backends set stream->fd on success,
+     * or set stream->open_status and return non-zero on failure without
+     * aborting the client (so the caller may retry). */
     int result;
     if (settings->type == GUAC_SERIAL_TYPE_LOCAL)
         result = guac_serial_local_open(stream, client, settings);
@@ -91,37 +141,45 @@ guac_serial_stream* guac_serial_stream_open(guac_client* client,
     else
         result = guac_serial_tcp_open(stream, client, settings);

-    /* Clean up and return NULL on failure (backend has already aborted) */
-    if (result != 0) {
-        pthread_mutex_destroy(&stream->write_lock);
-        guac_mem_free(stream);
-        return NULL;
-    }
+    /* Ensure the fd is left invalid on failure */
+    if (result != 0)
+        stream->fd = -1;

-    return stream;
+    pthread_mutex_unlock(&stream->write_lock);
+    return result;

 }

 int guac_serial_stream_write(guac_serial_stream* stream, const char* buffer,
         int length) {

-    int result = length;
-
     pthread_mutex_lock(&stream->write_lock);

+    /* Drop input while the transport is down (e.g. mid-reconnect) rather than
+     * blocking or writing to a stale descriptor */
+    if (stream->fd < 0) {
+        pthread_mutex_unlock(&stream->write_lock);
+        return length;
+    }
+
     /* Pace output byte-by-byte when a paste delay is configured */
     if (stream->paste_delay > 0) {

         for (int i = 0; i < length; i++) {

+            /* Bail promptly if the session is tearing down, so a long paste
+             * cannot hold up teardown */
+            if (stream->client->state != GUAC_CLIENT_RUNNING)
+                break;
+
             if (stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
                 guac_serial_rfc2217_send(stream, buffer + i, 1);
-            else if (guac_serial_stream_write_all(stream->fd, buffer + i, 1) != 1) {
-                result = -1;
+            else if (guac_serial_stream_write_all(stream->fd, buffer + i, 1) != 1)
+                /* The descriptor went bad; the read loop will detect the drop
+                 * and reconnect, so simply stop writing */
                 break;
-            }

-            usleep((useconds_t) stream->paste_delay * 1000);
+            guac_serial_stream_interruptible_sleep(stream, stream->paste_delay);

         }

@@ -132,13 +190,13 @@ int guac_serial_stream_write(guac_serial_stream* stream, const char* buffer,

         if (stream->backend == GUAC_SERIAL_BACKEND_RFC2217)
             guac_serial_rfc2217_send(stream, buffer, length);
-        else if (guac_serial_stream_write_all(stream->fd, buffer, length) != length)
-            result = -1;
+        else
+            guac_serial_stream_write_all(stream->fd, buffer, length);

     }

     pthread_mutex_unlock(&stream->write_lock);
-    return result;
+    return length;

 }

@@ -148,6 +206,12 @@ int guac_serial_stream_send_break(guac_serial_stream* stream) {

     pthread_mutex_lock(&stream->write_lock);

+    /* Ignore if the transport is currently down */
+    if (stream->fd < 0) {
+        pthread_mutex_unlock(&stream->write_lock);
+        return 0;
+    }
+
     switch (stream->backend) {

         case GUAC_SERIAL_BACKEND_LOCAL:
@@ -184,6 +248,60 @@ int guac_serial_stream_send_break(guac_serial_stream* stream) {

 }

+int guac_serial_stream_set_line(guac_serial_stream* stream,
+        guac_serial_control_line line, bool state) {
+
+    int result = 0;
+    const char* line_name = (line == GUAC_SERIAL_CONTROL_LINE_DTR)
+            ? "DTR" : "RTS";
+
+    pthread_mutex_lock(&stream->write_lock);
+
+    /* Ignore if the transport is currently down */
+    if (stream->fd < 0) {
+        pthread_mutex_unlock(&stream->write_lock);
+        return 0;
+    }
+
+    switch (stream->backend) {
+
+        case GUAC_SERIAL_BACKEND_LOCAL: {
+            int flag = (line == GUAC_SERIAL_CONTROL_LINE_DTR)
+                    ? TIOCM_DTR : TIOCM_RTS;
+            if (ioctl(stream->fd, state ? TIOCMBIS : TIOCMBIC, &flag) != 0) {
+                guac_client_log(stream->client, GUAC_LOG_WARNING, "Unable to "
+                        "set %s %s: %s", line_name, state ? "on" : "off",
+                        strerror(errno));
+                result = -1;
+            }
+            break;
+        }
+
+        case GUAC_SERIAL_BACKEND_TCP:
+            guac_client_log(stream->client, GUAC_LOG_WARNING, "Control-line "
+                    "toggles (DTR/RTS) require the rfc2217 transport; ignoring "
+                    "on raw TCP transport.");
+            break;
+
+        case GUAC_SERIAL_BACKEND_RFC2217: {
+            /* RFC2217 SET-CONTROL values: DTR ON=8, DTR OFF=9, RTS ON=11,
+             * RTS OFF=12 */
+            int value;
+            if (line == GUAC_SERIAL_CONTROL_LINE_DTR)
+                value = state ? 8 : 9;
+            else
+                value = state ? 11 : 12;
+            guac_serial_rfc2217_set_control(stream, value);
+            break;
+        }
+
+    }
+
+    pthread_mutex_unlock(&stream->write_lock);
+    return result;
+
+}
+
 void guac_serial_stream_close(guac_serial_stream* stream) {

     /* Nothing to do if never opened */
diff --git a/src/protocols/serial/stream.h b/src/protocols/serial/stream.h
index b3c7c542..339119bb 100644
--- a/src/protocols/serial/stream.h
+++ b/src/protocols/serial/stream.h
@@ -49,6 +49,24 @@ typedef enum guac_serial_backend {

 } guac_serial_backend;

+/**
+ * A modem control line which may be asserted or de-asserted on the serial
+ * stream.
+ */
+typedef enum guac_serial_control_line {
+
+    /**
+     * The Data Terminal Ready (DTR) line.
+     */
+    GUAC_SERIAL_CONTROL_LINE_DTR,
+
+    /**
+     * The Request To Send (RTS) line.
+     */
+    GUAC_SERIAL_CONTROL_LINE_RTS
+
+} guac_serial_control_line;
+
 /**
  * A transport-neutral, bidirectional serial byte stream. All reads are
  * performed directly on the file descriptor by the main worker thread; writes
@@ -99,25 +117,54 @@ typedef struct guac_serial_stream {
      */
     guac_client* client;

+    /**
+     * The guac_protocol_status describing the most recent failed open attempt.
+     * Used by the initial connect path to abort with an appropriate status.
+     */
+    int open_status;
+
 } guac_serial_stream;

 /**
- * Opens a serial byte stream using the transport described by the given
- * settings, dispatching to the local, TCP, or RFC2217 backend as appropriate.
- * On failure, the client is aborted and NULL is returned.
+ * Allocates a serial byte stream for the given client, without yet opening any
+ * transport. The returned stream has no open file descriptor (fd is -1) until
+ * guac_serial_stream_reopen() succeeds. This allows a single stream object to
+ * persist across reconnects for the lifetime of the session.
  *
  * @param client
- *     The client for which the stream is being opened.
+ *     The client for which the stream is being allocated.
  *
  * @param settings
  *     The parsed connection settings describing the transport to use.
  *
  * @return
  *     A newly-allocated serial stream which must be freed with
- *     guac_serial_stream_close(), or NULL if the connection could not be
- *     established.
+ *     guac_serial_stream_close().
+ */
+guac_serial_stream* guac_serial_stream_alloc(guac_client* client,
+        guac_serial_settings* settings);
+
+/**
+ * (Re)opens the transport for the given serial stream, closing any currently-
+ * open file descriptor and libtelnet session first, then dispatching to the
+ * local, TCP, or RFC2217 backend as appropriate. The stream's fd is swapped
+ * under its write lock so that a concurrent writer never touches a stale
+ * descriptor. On failure, the stream's open_status is set and the fd is left
+ * at -1; the client is NOT aborted, so the caller may retry.
+ *
+ * @param stream
+ *     The serial stream to (re)open.
+ *
+ * @param client
+ *     The client for which the transport is being opened.
+ *
+ * @param settings
+ *     The parsed connection settings describing the transport to use.
+ *
+ * @return
+ *     Zero on success, non-zero on failure.
  */
-guac_serial_stream* guac_serial_stream_open(guac_client* client,
+int guac_serial_stream_reopen(guac_serial_stream* stream, guac_client* client,
         guac_serial_settings* settings);

 /**
@@ -156,6 +203,28 @@ int guac_serial_stream_write(guac_serial_stream* stream, const char* buffer,
  */
 int guac_serial_stream_send_break(guac_serial_stream* stream);

+/**
+ * Asserts or de-asserts a modem control line (DTR or RTS) on the serial stream.
+ * For local transports the line is toggled via TIOCMBIS/TIOCMBIC; for RFC2217
+ * transports the corresponding COM-PORT-OPTION SET-CONTROL subnegotiation is
+ * sent. Raw TCP transports do not support control-line signalling; a warning
+ * is logged and the call is a no-op.
+ *
+ * @param stream
+ *     The serial stream on which to toggle the control line.
+ *
+ * @param line
+ *     The control line to toggle (DTR or RTS).
+ *
+ * @param state
+ *     true to assert (raise) the line, false to de-assert (lower) it.
+ *
+ * @return
+ *     Zero on success, non-zero on error.
+ */
+int guac_serial_stream_set_line(guac_serial_stream* stream,
+        guac_serial_control_line line, bool state);
+
 /**
  * Closes the given serial stream, releasing all associated resources. This
  * function is idempotent and safe to call with a NULL stream.
diff --git a/src/protocols/serial/tcp.c b/src/protocols/serial/tcp.c
index 9f825456..a45594a0 100644
--- a/src/protocols/serial/tcp.c
+++ b/src/protocols/serial/tcp.c
@@ -27,8 +27,10 @@
 #include <guacamole/protocol.h>
 #include <guacamole/tcp.h>

+#include <errno.h>
 #include <netinet/in.h>
 #include <netinet/tcp.h>
+#include <string.h>

 int guac_serial_tcp_open(guac_serial_stream* stream, guac_client* client,
         guac_serial_settings* settings) {
@@ -37,9 +39,30 @@ int guac_serial_tcp_open(guac_serial_stream* stream, guac_client* client,
     int fd = guac_tcp_connect(settings->hostname, settings->port,
             settings->timeout);
     if (fd < 0) {
-        guac_client_abort(client, GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND,
-                "Unable to connect to serial server \"%s\" port \"%s\".",
-                settings->hostname, settings->port);
+
+        int err = errno;
+
+        /* Distinguish an actively-refused or reset endpoint from other
+         * failures (e.g. name resolution, timeout) */
+        if (err == ECONNREFUSED) {
+            guac_client_log(client, GUAC_LOG_ERROR, "ser2net endpoint "
+                    "\"%s:%s\" refused the connection: %s", settings->hostname,
+                    settings->port, strerror(err));
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+        }
+        else if (err == ECONNRESET) {
+            guac_client_log(client, GUAC_LOG_ERROR, "Connection to ser2net "
+                    "endpoint \"%s:%s\" was reset: %s", settings->hostname,
+                    settings->port, strerror(err));
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_UNAVAILABLE;
+        }
+        else {
+            guac_client_log(client, GUAC_LOG_ERROR, "Unable to connect to "
+                    "serial server \"%s\" port \"%s\".", settings->hostname,
+                    settings->port);
+            stream->open_status = GUAC_PROTOCOL_STATUS_UPSTREAM_NOT_FOUND;
+        }
+
         return -1;
     }