Skip to content

tcsetattr is refused with ENOTTY, so a program cannot enter raw mode or keep Ctrl+C from killing it; TCGETS, TIOCGWINSZ and SIG_IGN report success having done nothing (0.13.1, 0.13.5) #36

Description

@yspbwx2010

A program that puts its terminal into raw mode with the ordinary tcgetattr /
cfmakeraw / tcsetattr sequence dies on Ctrl+C when it is built against
openkal-musl, and reads the same keystroke as the byte 0x03 when the identical
source is built against the host C library. The cause is that tcsetattr never
reaches the terminal at all: the port's ioctl dispatcher answers TCGETS and
TIOCGWINSZ and refuses everything else, so the structure musl composes for it
never reaches the terminal and comes back as ENOTTY.

That structure was composed over bytes TCGETS never wrote, which is the second
defect and the quieter one. tcgetattr returns 0 and leaves the caller's struct
termios exactly as it found it, so what cfmakeraw edits and tcsetattr then
offers to the terminal is the caller's own uninitialised stack; if TCSETS were
accepted tomorrow and nothing else changed, that is what would be written to
the terminal. TIOCGWINSZ answers the same way.

Both fallbacks are closed too. rt_sigaction refuses any handler that is not
SIG_DFL or SIG_IGN, and it accepts SIG_IGN without installing it, so a program
that tries to ignore Ctrl+C instead is told that it succeeded and is killed by
Ctrl+C anyway.

A report from a consumer, with the two programs it rests on below.

Environment

openkal 0.12.0 (specification), openkal-linux 0.12.0, openkal-musl 0.13.1
vendoring musl 1.2.5, openkal-llvm-runtime 0.9.1. Built with mcpp 2026.9.15.2
and clang 22.1.8 (llvm-project ca7933e47d3a3451d81e72ac174dcb5aa28b59d1) for
x86_64-linux-musl, with an x86_64-linux-gnu build of the same source as the
control. Linux 7.2.4, host glibc 2.44, and the terminal is a tmux 3.7c pane;
both builds ran in one tmux server, one 100x40 pane each, started together, the
binary being the pane's own process with no wrapper shell.

I also read the newest published sources read-only to check this is not already
fixed: openkal-musl 0.13.5 and openkal 0.12.0, the newest release of the
specification. The relevant lines are unchanged there; line numbers for both
versions are given below.

The claims below are carried by libc return values and errno, by a sentinel
written into the caller's struct termios and struct winsize before each call,
by /proc//status, and by the request number of each ioctl that reaches the
kernel, read off a gdb syscall catchpoint. This machine has no strace or
ltrace; gdb answers the one question that needed a trace.

Reproduction

Two programs, each one the whole of its package. Fifty-three lines for the
first. It reports what it asked for and what it got back, then prints every
byte that arrives, so Ctrl+C shows up either as 0x03 or as the program's death.
The struct is pre-filled with 0x5a before each tcgetattr, so a call that
reports success and writes nothing is visible as such.

// Does entering raw mode actually take effect? Read back what we set, then
// report every byte that arrives, so Ctrl+C shows up either as 0x03 or as death.
#include <cstdio>
#include <cerrno>
#include <csignal>
#include <cstring>
#include <termios.h>
#include <unistd.h>

static void show(const char* tag, const termios& t) {
  std::printf("%-10s lflag=0x%08lx ISIG=%d ICANON=%d ECHO=%d cc[VMIN]=%d cc[VTIME]=%d\n",
              tag, (unsigned long)t.c_lflag, !!(t.c_lflag & ISIG),
              !!(t.c_lflag & ICANON), !!(t.c_lflag & ECHO),
              (int)t.c_cc[VMIN], (int)t.c_cc[VTIME]);
}

int main() {
  std::setvbuf(stdout, nullptr, _IONBF, 0);
  std::printf("pid=%d isatty(0)=%d\n", (int)getpid(), isatty(0));

  struct sigaction sa{}; sa.sa_handler = [](int){};
  errno = 0;
  std::printf("sigaction(SIGINT,handler) rc=%d errno=%d\n", sigaction(SIGINT, &sa, nullptr), errno);

  termios orig;
  std::memset(&orig, 0x5a, sizeof orig);   // sentinel: does TCGETS write anything?
  errno = 0;
  int rc = tcgetattr(0, &orig);
  std::printf("tcgetattr rc=%d errno=%d\n", rc, errno);
  show("before", orig);

  termios raw = orig;
  cfmakeraw(&raw);
  show("want", raw);
  errno = 0;
  rc = tcsetattr(0, TCSANOW, &raw);
  std::printf("tcsetattr rc=%d errno=%d\n", rc, errno);

  termios back;
  std::memset(&back, 0x5a, sizeof back);
  errno = 0;
  rc = tcgetattr(0, &back);
  std::printf("tcgetattr(readback) rc=%d errno=%d\n", rc, errno);
  show("readback", back);

  std::printf("reading; press keys, 'q' quits\n");
  for (char c; read(0, &c, 1) == 1; ) {
    std::printf("byte 0x%02x\n", (unsigned char)c);
    if (c == 'q') break;
  }
  tcsetattr(0, TCSANOW, &orig);
  std::printf("restored, exiting\n");
}

mcpp.toml:

[package]
name    = "isigprobe"
version = "0.1.0"

[targets.isigprobe]
kind = "bin"
main = "src/main.cpp"

[toolchain]
default = "llvm@22.1.8"

[build]
target = "x86_64-linux-musl"

[target.'cfg(all(os = "linux", env = "musl"))'.dependencies]
openkal-llvm-runtime = "0.9.1"

Two builds, and the binaries must be run from a real terminal — a tmux pane or
any pty. Over a pipe the program still asks for raw mode, and both libraries
refuse it in the same words: measured with printf 'abq' | ..., the musl build
and the glibc build both report isatty(0)=0 and rc=-1 errno=25 from tcgetattr,
from tcsetattr and from the readback. The control stops distinguishing
anything, and Ctrl+C never reaches a piped program in the first place.

mcpp build --target x86_64-linux-musl     # then run target/x86_64-linux-musl/*/bin/isigprobe
mcpp build --target x86_64-linux-gnu      # then run target/x86_64-linux-gnu/*/bin/isigprobe

Type a, b, then Ctrl+C, then q.

The signal side is the second program, built the same way with the package and
target names changed. It asks for SIG_IGN, asks back what the disposition is,
and then sits in read, so that a SIGINT can be sent to it from outside and the
answer is whether it is still there afterwards.

// Does SIG_IGN actually take effect? Report the return value, then sit in read
// and let someone send SIGINT: if the ignore was installed the read continues.
#include <cstdio>
#include <cerrno>
#include <csignal>
#include <unistd.h>

int main() {
  std::setvbuf(stdout, nullptr, _IONBF, 0);
  errno = 0;
  void* rc = (void*)signal(SIGINT, SIG_IGN);
  std::printf("signal(SIGINT,SIG_IGN) rc=%p errno=%d\n", rc, errno);
  struct sigaction old{};
  errno = 0;
  int q = sigaction(SIGINT, nullptr, &old);
  std::printf("sigaction(query) rc=%d errno=%d handler=%p\n", q, errno, (void*)old.sa_handler);
  std::printf("ready\n");
  for (char c; read(0, &c, 1) == 1; ) std::printf("byte 0x%02x\n", (unsigned char)c);
  std::printf("read ended, exiting\n");
}

Run that one in a pane of its own and send kill -INT to its pid.

What happens

Both panes below are from one run of one tmux server, captured with
capture-pane -S -. Built for x86_64-linux-musl: the last thing on screen is
the terminal's own echo of Ctrl+C, because the terminal was never taken out of
canonical mode, and the final line is tmux's own, not the program's.

pid=1 isatty(0)=1
sigaction(SIGINT,handler) rc=-1 errno=38
tcgetattr rc=0 errno=0
before     lflag=0x5a5a5a5a ISIG=0 ICANON=1 ECHO=1 cc[VMIN]=90 cc[VTIME]=90
want       lflag=0x5a5a5a10 ISIG=0 ICANON=0 ECHO=0 cc[VMIN]=1 cc[VTIME]=0
tcsetattr rc=-1 errno=25
tcgetattr(readback) rc=0 errno=0
readback   lflag=0x5a5a5a5a ISIG=0 ICANON=1 ECHO=1 cc[VMIN]=90 cc[VTIME]=90
reading; press keys, 'q' quits
ab^C
Pane is dead (signal 2, Thu Sep 17 18:05:21 2026)

tmux reports that pane as pane_dead=1 pane_dead_status= pane_dead_signal=2,
and /proc/2430925, the pane's own process, is gone.

The same source built for x86_64-linux-gnu, in the other pane of the same tmux
server, survives the keystroke and reads it as data. Its own output is in raw
mode and therefore arrives without carriage returns; I have reflowed it here,
and nothing else is changed:

pid=2430930 isatty(0)=1
sigaction(SIGINT,handler) rc=0 errno=0
tcgetattr rc=0 errno=0
before     lflag=0x00008a3b ISIG=1 ICANON=1 ECHO=1 cc[VMIN]=1 cc[VTIME]=0
want       lflag=0x00000a30 ISIG=0 ICANON=0 ECHO=0 cc[VMIN]=1 cc[VTIME]=0
tcsetattr rc=0 errno=0
tcgetattr(readback) rc=0 errno=0
readback   lflag=0x00000a30 ISIG=0 ICANON=0 ECHO=0 cc[VMIN]=1 cc[VTIME]=0
reading; press keys, 'q' quits
byte 0x61
byte 0x62
byte 0x03
byte 0x71
restored, exiting

That pane then ended normally: pane_dead=1 pane_dead_status=0.

Three numbers in those two columns carry the report. First, tcsetattr rc=-1 errno=25 against rc=0 errno=0: raw mode is refused with ENOTTY on a stream
the port itself has just agreed is a terminal, since isatty(0) is 1 and TCGETS
was answered. Second, sigaction(SIGINT,handler) rc=-1 errno=38: ENOSYS, so a
program cannot install a handler and clean up for itself. Third, the sentinel:
before lflag=0x5a5a5a5a ... cc[VMIN]=90 cc[VTIME]=90 is the 0x5a pattern the
program wrote before calling. tcgetattr returned 0 and wrote nothing at all. The
glibc control in the other pane reads the terminal's actual state,
lflag=0x00008a3b ISIG=1 ICANON=1 ECHO=1. The readback after the failed
tcsetattr is the untouched sentinel too, so a program that tries to detect the
failure by reading the state back learns nothing either.

The kernel agrees about the signal, read from the same two processes while they
were blocked in read. The musl build shows SigBlk: 0000000000000000,
SigIgn: 0000000000001000 — SIGPIPE, with no SIGINT bit — and
SigCgt: 0000000000000000; the glibc build in the other pane shows
SigCgt: 0000000000000002. Nothing is caught because nothing was installed,
and the process those numbers came from is the one that then died of SIGINT.

Neither the terminal nor the kernel is implicated. In a pane of the same kind,
a plain stty raw -echo -isig followed by cat -v prints ab^C, and cat is
still there after the keystroke: /proc/ exists with State: S (sleeping).
The pty delivers 0x03 as data as soon as something manages to clear ISIG on it.

One incidental thing, so that the musl transcript above is not misread:
getpid() returns 1 under openkal-musl 0.13.1, which is why it prints pid=1
while the pane's own process is host pid 2430925. That is unrelated to this
report and is not a container artifact.

Where it comes from

The ioctl dispatcher recognises two requests and refuses the rest. In
openkal-musl 0.13.1, port/src/okm_syscall.c:1687-1690:

if (!interactive) return -ENOTTY;
if ((unsigned long)a2 == TCGETS)     return 0;
if ((unsigned long)a2 == TIOCGWINSZ) return 0;
return -ENOTTY;

musl's tcsetattr issues TCSETS plus the action
(musl/src/termios/tcsetattr.c:11, return ioctl(fd, TCSETS+act, tio);), so
TCSETS, TCSETSW and TCSETSF all land on that last line. The same four lines are
unchanged at 0.13.5, at port/src/okm_syscall.c:1712-1715.

This is not a structure-layout problem — termios against termios2, a c_lflag at
the wrong offset — and it is not the kernel rewriting what was written. No
TCSETS request reaches the kernel at all. Under a gdb syscall catchpoint on a
pty, the musl build issues five kernel ioctls over the whole of such a run, and
every one of them is TCGETS, 0x5401; 0x5402 never appears. The glibc control,
on a pty of the same kind, issues 0x802c542a (TCGETS2) and 0x402c542b
(TCSETS2), in the places you would expect them. The dispatcher refuses TCSETS
in its own code, which is exactly why the failure is visible to the caller as
errno 25 at the tcsetattr return.

Those five kernel TCGETS calls are the port's own, not the program's. Every
intercepted ioctl on a stream computes interactive first
(okm_syscall.c:1652-1654 in 0.13.1, :1677-1679 in 0.13.5) by calling
kal_stream_props, and openkal-linux 0.12.0 implements that as a real TCGETS
into a 64-byte local buffer whose contents it then discards, keeping only
whether the call succeeded (src/stream.cpp:83-92, the ioctl at :89-91). A
backtrace taken at every one of the five hits shows the same two frames each
time — kal_stream_props at src/stream.cpp:89, called from the dispatcher at
okm_syscall.c:1654 — under three different callers: once from isatty asking
TIOCGWINSZ (musl/src/unistd/isatty.c:9), twice from tcgetattr asking TCGETS
(musl/src/termios/tcgetattr.c:6), and twice from tcsetattr asking TCSETS
(musl/src/termios/tcsetattr.c:11). Each of those five calls becomes one kernel
TCGETS and nothing else; the TCSETS the program asked with is answered inside
the dispatcher and never issued.

The C library layer is not modified and is not at fault, by the port's own
records. musl/PATCHES.md lists every line this port changes in musl 1.2.5 and
calls that list exhaustive, and it lists the excluded and replaced sources
separately; none of the three lists names anything under src/termios, and the
manifest's own exclusion list agrees (mcpp.toml:112-230 in 0.13.1, seventeen
exclusions, none of them a termios or ioctl source). The vendored
musl/src/termios is identical in 0.13.1 and 0.13.5. Upstream cfmakeraw does
clear ISIG (musl/src/termios/cfmakeraw.c:8), and the glibc column above shows
it doing so on this machine: before lflag=0x00008a3b ISIG=1 becomes want lflag=0x00000a30 ISIG=0. The musl column shows less than that, and shows it on
the wrong bytes: the sentinel 0x5a already has bit 0 clear, so only ICANON,
ECHO and ECHONL visibly change there — in a structure that was never the
terminal's state to begin with and that then never reached the terminal.

The TCGETS answer has a second, quieter defect of the same family. It reports
success and writes nothing into the caller's struct, which musl's tcgetattr
does not pre-zero (musl/src/termios/tcgetattr.c:6). A caller therefore reads
back whatever was in its own stack frame, and reads it as the terminal's state.
The port has the kernel's real termios in hand at that moment — kal_stream_props
has just read it into the buffer described above — and copies none of it.

The TIOCGWINSZ answer is the same defect rather than an exception to it. The
file's own comment a few lines above, at port/src/okm_syscall.c:1681-1686
(:1706-1711 in 0.13.5), names reporting success having done nothing as the one
shape this file must never produce, and rests the winsize branch on winsize' being already zeroed by the caller. musl's own isatty is the counterexample in the same tree: it declares struct winsize wsz;uninitialised and passes it straight in (musl/src/unistd/isatty.c:8-9, in 0.13.1 and 0.13.5 alike). A variation on the sentinel above measures it:std::memset(&ws, 0x5a, sizeof
ws); ioctl(0, TIOCGWINSZ, &ws);in place of the tcgetattr, three lines in the same arrangement. It getsioctl(TIOCGWINSZ) rc=0 errno=0 rows=23130
cols=23130— 23130 is 0x5a5a, the untouched sentinel — where the glibc control in the other pane readsrows=40 cols=100`, the pane's real size. So both
recognised requests report success having done nothing.

The signal side is port/src/okm_syscall.c:2506-2507 in 0.13.1 (:2531-2532 in
0.13.5): if (h == 0 || h == 1) return 0; for SIG_DFL and SIG_IGN, then
return -ENOSYS; for everything else. So the second way out — leave the
terminal alone and catch SIGINT — is closed by a refusal.

The third way out is closed by a silent no-op, which is the worse of the two.
SIG_IGN is not honoured: that line returns success without performing any
kernel sigaction, so the kernel disposition stays as it was, and the enquiry
then answers SIG_DFL because the old action was zeroed a few lines above
(:2496-2503 in 0.13.1, :2521-2528 in 0.13.5). Measured with the second program
above, as the pane's own process, the musl build prints
signal(SIGINT,SIG_IGN) rc=0 errno=0 and then sigaction(query) rc=0 errno=0 handler=0, /proc//status shows SigIgn: 0000000000001000 with no SIGINT
bit in it, and kill -INT on that pid kills it: pane_dead=1,
pane_dead_signal=2. The glibc control in the same arrangement prints
rc=(nil), then handler=0x1, shows SigIgn: 0000000000000002, and survives.
A program that tries to ignore Ctrl+C is therefore killed by it, is told it
succeeded, and cannot find out by asking.

Finally, the capability exists one layer down and this port does not use it.
openkal-linux 0.12.0 implements kal_terminal_set_mode as a read-modify-write of
the kernel termios with a real TCSETS (src/terminal.cpp:52-80, the write at
:73-74), and advertises KAL_IFACE_TERMINAL. In openkal-musl, grep -rn kal_terminal port/ returns nothing, in 0.13.1 and in 0.13.5 alike: the ioctl
branch answers the terminal question itself out of the stream properties and
never reaches openkal.terminal.

Impact

Any program that enters raw mode is affected, which in practice means every
full-screen or key-at-a-time program: an editor, a pager, a progress display
that wants a single keystroke, a REPL with its own line editing. On
openkal-musl such a program cannot enter raw mode at all, so it gets line-at-a-
time input with echo instead of keystrokes; it cannot intercept Ctrl+C, so the
terminal kills it; and it cannot fall back on a handler or on ignoring the
signal, so it has no way to restore anything on the way out. One of those
failures is at least loud — tcsetattr returns -1 with ENOTTY, which a careful
program can notice. The other three are not. tcgetattr and TIOCGWINSZ report
success and leave the caller's structure as it found it, and SIG_IGN reports
success and installs nothing, so a program that checks its return values
carefully still acts on uninitialised bytes and still dies on the keystroke.

What I would expect, and possible directions

I would expect tcsetattr on an interactive stream either to take effect or to
fail for a reason the terminal actually has, and tcgetattr and TIOCGWINSZ
either to fill the caller's structure or to fail; I would expect a signal
disposition that is accepted to be installed, and refused otherwise; and I
would expect a program to be able to keep Ctrl+C from killing it, by one route
or another.

How to get there is yours to decide, but the shape of the gap may be worth
saying. Routing the ioctl branch to openkal.terminal — kal_terminal_get_mode
and kal_terminal_set_mode, which openkal-linux already implements correctly —
looks like the natural direction, and it would fix line editing and echo. It
would not by itself fix Ctrl+C: the mode word in openkal 0.12.0
include/openkal/terminal.h:31-32 defines only KAL_TERM_LINE_EDIT and
KAL_TERM_ECHO, with no position for signal generation, and openkal-linux's
set_mode touches exactly those two lflag bits by design (src/terminal.cpp:63-66)
and preserves the rest, so ISIG would survive. Clearing ICANON and ECHO while
ISIG stays set is precisely the state in which a full-screen program takes
keystrokes as data and is still killed by Ctrl+C. That half looks like a
question for the specification rather than for this port, and I am happy to file
it there if you would rather have it as its own report.

Two things I did not establish, so that they are not read as measurements.
Whether wiring the port to kal_terminal_* is a change to okm_syscall.c alone or
also needs link plumbing is untested — openkal-linux exports the symbols, but I
did not try to call them from a program built through openkal-musl. And I only
read the Linux implementation, so I do not know whether the other platform
implementations have the same gap or an equivalent to expose.

Happy to test a fix on this setup and report back.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions