For the record, I finally found a robust way to detect EOF in all cases
(since SIGHUP doesn't cover a piped EOF and ncurses doesn't update the
stdin FILE pointer).
We can detect the EOF/HUP state using `poll()` on STDIN_FILENO.
This covers both the EOF of a piped file and an SSH disconnection (PTY
tear-down).
In both cases, POLLHUP is set.
Because getch() returns ERR for both "no data available" and "EOF," and
does not set a unique errno for the latter,
poll() seems to be the only reliable way to distinguish them once the
ncurses input buffer is drained.
In all cases `errno` is never set on my Ubuntu system and will typically be
an old unrelated value.
As such, `errno` is useless.
```c
#include <ncurses.h>
#include <errno.h>
#include <stdio.h>
#include <poll.h>
#include <unistd.h>
int main() {
FILE *f = fopen("trace.log", "w");
initscr();
halfdelay(5);
for (int i = 0; i < 100; ++i) {
errno = 0;
int ch = getch();
int err = errno;
fprintf(f, "getch: %d, errno: %d\n", ch, err);
fflush(f);
if (ch != ERR)
continue;
struct pollfd pfd = { .fd = STDIN_FILENO, .events = 0 };
if (poll(&pfd, 1, 0) > 0) {
// POLLHUP: Other end closed (Pipe/SSH)
// POLLERR: Device error (Terminal crash)
// POLLNVAL: Descriptor is invalid (Process-level closure)
if (pfd.revents & (POLLHUP | POLLERR | POLLNVAL)) {
fprintf(f, "Input stream is no longer valid and buffer
empty.\n");
break;
}
}
}
endwin();
fclose(f);
}
```
On Mon, Mar 30, 2026 at 8:05 AM Klaas van Aarsen <[email protected]>
wrote:
> > agreed - some improvement is possible there. The manpage doesn't
> mention EOF
> > (it could also hint that checking errno would be useful after receiving
> ERR,
> > but there's no guarantee that there aren't other calls/other errors in
> the
> > interval between calling read and checking errno).
>
> It actually appears errno is unreliable in this context.
> It is usually not set when getch returns ERR.
> It could even have a value from some unrelated earlier operation.
>
> Instead of suggesting errno, I believe the manpage should clarify that to
> detect specific conditions like EOF from a pipe or a terminal disconnection,
> the application should monitor signals (like SIGHUP) or check the file
> descriptor state directly.
>
> On Sun, Mar 29, 2026 at 7:06 PM Klaas van Aarsen <
> [email protected]> wrote:
>
>> >> Regarding the suggestion of EBADF, it is my understanding that the
>> kernel
>> >> must keep file descriptor 0 open (valid) even after a disconnect to
>> prevent
>> >> immediate FD reuse.
>> > either it's closed, or it's not.
>> > ncurses doesn't reopen it.
>> > Since ncurses doesn't reopen it, I don't see any reason why the dangling
>> > symlink would be relevant.
>>
>> To clarify my earlier point regarding EBADF:
>>
>> If the kernel were to simply close file descriptor 0 upon a disconnect,
>> the very next file opened by the process (or a library) would be assigned
>> FD 0, as it is the lowest available slot.
>> This would cause catastrophic confusion, as code expecting stdin would
>> suddenly be reading from a random file or socket.
>>
>> Instead, the descriptor remains allocated to the process but points to a
>> defunct terminal (often following a SIGHUP).
>> This is why read() returns 0 (EOF) and errno remains 0, rather than
>> triggering EBADF.
>>
>> We can detect this specific "ghost" state using isatty(STDIN_FILENO).
>> Upon disconnect, isatty will return 0 (false) because the underlying
>> terminal device is no longer associated with the descriptor, even though
>> the descriptor itself hasn't been "closed."
>> This approach doesn't work with a piped file though.
>>
>> My mention of the dangling symlink was simply to illustrate this state:
>> the process holds a valid descriptor to a path/device that no longer exists
>> in the active filesystem.
>>
>>
>> On Sun, Mar 29, 2026 at 6:38 PM Klaas van Aarsen <
>> [email protected]> wrote:
>>
>>> > If you're able to reproduce the problem, and modify the program to
>>> report
>>> > the errno value, then we can discuss whether the errno value matches
>>> the
>>> > expected behavior.
>>>
>>> I have run the requested tests to capture errno during both a pipe EOF
>>> and a forced SSH disconnect.
>>>
>>> Environment
>>> -----------
>>> Ubuntu 22.04.5 LTS in WSL2
>>> ncurses 6.3.20211021
>>>
>>> Test Program
>>> ------------
>>> #include <ncurses.h>
>>> #include <errno.h>
>>> #include <stdio.h>
>>>
>>> int main() {
>>> FILE *f = fopen("trace.log", "w");
>>> initscr();
>>> halfdelay(5);
>>> for (int i = 0; i < 100; ++i) {
>>> errno = 0;
>>> int ch = getch();
>>> int err = errno;
>>> fprintf(f, "getch: %d, errno: %d\n", ch, err);
>>> fflush(f);
>>> }
>>> endwin();
>>> fclose(f);
>>> }
>>>
>>>
>>> Scenario 1: Pipe EOF
>>> --------------------
>>> $ echo "hi" | ./a.out ; cat trace.log
>>>
>>> Results show getch: 104, 105, 10 followed by consistent getch: -1,
>>> errno: 0.
>>>
>>>
>>> Scenario 2: SSH Disconnect
>>> --------------------------
>>> 1. SSH into host.
>>> 2. Run ./a.out.
>>> 3. Hard-close the terminal window (sending no logout/exit).
>>> 4. Inspect log after 30 seconds.
>>>
>>> Trace Log Result:
>>> getch: 72, errno: 0 (H)
>>> getch: 105, errno: 0 (i)
>>> getch: -1, errno: 0
>>> getch: -1, errno: 0
>>> ... [continues until loop end] ...
>>> getch: -1, errno: 0
>>>
>>>
>>> Scenario 3: Manually closing stdin (FD 0)
>>> -----------------------------------------
>>> To test if getch() would report EBADF, I added close(0) inside the loop.
>>>
>>> Result: The loop completed instantly (ignoring the halfdelay timeout).
>>> getch() returned -1 for every iteration, but errno remained 0.
>>>
>>> This demonstrates that even when the file descriptor is explicitly
>>> invalid (EBADF condition), getch() does not pass that error information
>>> through to the caller.
>>>
>>>
>>> Conclusion
>>> ----------
>>> In all cases, ncurses reports ERR but the system does not report EBADF
>>> or any other error via errno.
>>> This suggests that the terminal disconnect is being treated as a
>>> persistent EOF (0) rather than a file descriptor error.
>>>
>>> On Sun, Mar 29, 2026 at 5:32 PM Thomas Dickey <
>>> [email protected]> wrote:
>>>
>>>> On Sun, Mar 29, 2026 at 12:15:45PM +0200, Klaas van Aarsen wrote:
>>>> > > man read(2)
>>>> > > EBADF fd is not a valid file descriptor or is not open for
>>>> reading.
>>>> > > Klaas is describing a condition which isn't in the manpage - ymmv.
>>>> >
>>>> > Regarding the suggestion of EBADF, it is my understanding that the
>>>> kernel
>>>> > must keep file descriptor 0 open (valid) even after a disconnect to
>>>> prevent
>>>> > immediate FD reuse.
>>>>
>>>> either it's closed, or it's not.
>>>>
>>>> ncurses doesn't reopen it.
>>>>
>>>> Since ncurses doesn't reopen it, I don't see any reason why the dangling
>>>> symlink would be relevant.
>>>>
>>>> Checking errno after the failed call would be more useful than making
>>>> a signal handler :-)
>>>>
>>>> > If read() were to return EBADF while the descriptor is still
>>>> allocated to
>>>> > the process, it would technically contradict the POSIX definition of
>>>> that
>>>> > error.
>>>>
>>>> If you're able to reproduce the problem, and modify the program to
>>>> report
>>>> the errno value, then we can discuss whether the errno value matches the
>>>> expected behavior.
>>>>
>>>> > While the kernel could conceivably return an error like EIO or ENXIO
>>>> to
>>>> > signal a "dead" device, treating a terminal disconnect as an "end of
>>>> > stream" (returning 0) appears to be the standard behavior for
>>>> > pseudo-terminals across most modern Unix-like systems.
>>>> > This ensures the stream is closed gracefully without releasing the
>>>> > descriptor slot prematurely.
>>>>
>>>> ncurses is using the read directly (stream usually refers to things
>>>> opened via fopen).
>>>>
>>>> initscr (used in this program) calls newterm
>>>>
>>>> if (newterm(name, stdout, stdin) == NULL) {
>>>>
>>>> newterm copies the file descriptor:
>>>>
>>>> SP_PARM->_ifd = fileno(_ifp);
>>>>
>>>> and after that, it ignores any buffering that the caller may have on
>>>> that
>>>> file descriptor. ncurses never closes that file descriptor. If you got
>>>> EBADF, then that would indicate that the connection was closed (either
>>>> end of a connection can close it).
>>>>
>>>> > I appreciate the technical dialogue on this.
>>>> >
>>>> >
>>>> > On Sun, Mar 29, 2026 at 11:59 AM Thomas Dickey <
>>>> [email protected]>
>>>> > wrote:
>>>> >
>>>> > > On Sun, Mar 29, 2026 at 11:40:56AM +0200, Stian Skjelstad wrote:
>>>> > > > > Actually what you're telling us is that there's a defect in
>>>> (presumably
>>>> > > > Linux)
>>>> > > > > which should be documented in read(2). Once you've gotten it
>>>> > > documented
>>>> > > > in
>>>> > > > >the proper place (Linux manpages), then others can use the
>>>> > > documentation.
>>>> > > >
>>>> > > > It is normal for read() to return 0, without an error when EOF is
>>>> > > reached.
>>>> > > > This is how you detect EOF/closed on sockets and files. Should
>>>> TTY behave
>>>> > > > differently?
>>>> > >
>>>> > > man read(2)
>>>> > >
>>>> > > EBADF fd is not a valid file descriptor or is not open for
>>>> reading.
>>>> > >
>>>> > > Klaas is describing a condition which isn't in the manpage - ymmv.
>>>> > >
>>>> > > --
>>>> > > Thomas E. Dickey <[email protected]>
>>>> > > https://invisible-island.net
>>>> > >
>>>>
>>>> --
>>>> Thomas E. Dickey <[email protected]>
>>>> https://invisible-island.net
>>>>
>>>