From c48fedb0c8b77be79c5e2f6fef2b7fc2e7e81248 Mon Sep 17 00:00:00 2001 From: Frank Hunleth Date: Mon, 12 Oct 2026 16:24:24 -0400 Subject: [PATCH 1/2] Fail on open if tcsetattr doesn't set bits/parity This fixes a confusing situation where opening a pseudo-terminal would "work" the first time and then fail afterwards if requesting 7 data bits and parity. Pseudo-terminals only support 8n1, so the subsequent failures were correct but unexpected. This could catch other silent failures that appeared to work in the past. --- lib/circuits_uart.ex | 5 +++++ src/uart_comm_unix.c | 26 ++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/circuits_uart.ex b/lib/circuits_uart.ex index acd2798..e517842 100644 --- a/lib/circuits_uart.ex +++ b/lib/circuits_uart.ex @@ -192,6 +192,11 @@ defmodule Circuits.UART do of the Circuits.UART server pid as returned by `start_link/1`. The default value is `:name`. + On Linux and macOS, opening or configuring a port returns `{:error, :einval}` + if the driver fails to set the requested data bits, stop bits, or parity. + Linux pseudo-terminals, such as those created by `socat`, require + `data_bits: 8` and `parity: :none`. + The following options are supported on Linux only: * `:rs485_enabled` - (`true` or `false`) enable RS485 mode. diff --git a/src/uart_comm_unix.c b/src/uart_comm_unix.c index 18832c1..06762b6 100644 --- a/src/uart_comm_unix.c +++ b/src/uart_comm_unix.c @@ -240,7 +240,8 @@ static int clear_custom_speed(int fd) static int uart_config_line(int fd, const struct uart_config *config) { struct termios options; - tcgetattr(fd, &options); + if (tcgetattr(fd, &options) < 0) + return -1; // Set up the speed int baud_constant = to_baudrate_constant(config->speed); @@ -317,7 +318,28 @@ static int uart_config_line(int fd, const struct uart_config *config) options.c_cc[VTIME] = 0; // Set everything - return tcsetattr(fd, TCSANOW, &options); + if (tcsetattr(fd, TCSANOW, &options) < 0) + return -1; + + struct termios actual; + if (tcgetattr(fd, &actual) < 0) + return -1; + + // tcsetattr() may succeed even when only some settings were applied. + tcflag_t mask = CSIZE | CSTOPB | PARENB; + if (options.c_cflag & PARENB) { + mask |= PARODD; +#ifdef CMSPAR + mask |= CMSPAR; +#endif + } + + if ((actual.c_cflag & mask) != (options.c_cflag & mask)) { + errno = EINVAL; + return -1; + } + + return 0; } /** From a159a1c3ba82f90b6bf22c324070f634ff8a589b Mon Sep 17 00:00:00 2001 From: Frank Hunleth Date: Mon, 12 Oct 2026 16:30:50 -0400 Subject: [PATCH 2/2] Add pseudo-terminal test from PR 227 This verifies the previous commit that detects tcsetattr failures. --- test/pty_test.exs | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test/pty_test.exs diff --git a/test/pty_test.exs b/test/pty_test.exs new file mode 100644 index 0000000..ed402e6 --- /dev/null +++ b/test/pty_test.exs @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 Tallak Tveide +# +# SPDX-License-Identifier: Apache-2.0 +# +defmodule PtyTest do + use ExUnit.Case + alias Circuits.UART + + @skip_reason (cond do + :os.type() != {:unix, :linux} -> "These tests require Linux pseudo-terminals" + System.find_executable("socat") == nil -> "socat is not installed" + true -> false + end) + @moduletag skip: @skip_reason + + setup do + port = + Port.open({:spawn_executable, System.find_executable("socat")}, [ + :binary, + :stderr_to_stdout, + args: ["-d", "-d", "pty,raw,echo=0", "pty,raw,echo=0"] + ]) + + {:os_pid, os_pid} = Port.info(port, :os_pid) + on_exit(fn -> System.cmd("kill", [to_string(os_pid)]) end) + + uart = start_supervised!(UART) + %{pty: pty_name(port, ""), uart: uart} + end + + defp pty_name(port, output) do + case Regex.run(~r/PTY is (\S+)/, output, capture: :all_but_first) do + [name] -> + name + + nil -> + receive do + {^port, {:data, data}} -> pty_name(port, output <> data) + after + 2000 -> flunk("socat did not open a pty: #{output}") + end + end + end + + test "supported settings work on repeated opens and reconfiguration", %{pty: pty, uart: uart} do + for _ <- 1..3 do + assert :ok = UART.open(uart, pty, speed: 19200, data_bits: 8, parity: :none) + assert :ok = UART.configure(uart, stop_bits: 2) + assert :ok = UART.configure(uart, stop_bits: 1) + assert :ok = UART.close(uart) + end + end + + for options <- [ + [parity: :even], + [parity: :odd], + [parity: :mark], + [parity: :space], + [data_bits: 5], + [data_bits: 6], + [data_bits: 7] + ] do + test "unsupported settings #{inspect(options)} fail on every open", %{pty: pty, uart: uart} do + for _ <- 1..2 do + assert {:error, :einval} = + UART.open(uart, pty, [speed: 19200] ++ unquote(options)) + end + + assert :ok = UART.open(uart, pty, data_bits: 8, parity: :none) + end + + test "unsupported settings #{inspect(options)} fail on reconfiguration", %{ + pty: pty, + uart: uart + } do + assert :ok = UART.open(uart, pty) + assert {:error, :einval} = UART.configure(uart, unquote(options)) + assert :ok = UART.configure(uart, data_bits: 8, parity: :none) + end + end +end