How We Tricked ncurses Into Running Without a Terminal (And Got Test Coverage)
Or: Yes, You CAN Emulate a TTY in 2025 – Here’s How
The Problem: Tests That Demand a Real Terminal
We were porting MCL (a MUD client from the early 2000s) from C++ to Rust, and hit a classic problem: some tests refuse to run in CI/CD environments.
#[test]
fn test_init_curses() {
if !has_tty() {
eprintln!("SKIP: requires a TTY (run from a real terminal)");
return;
}
// Test ncurses initialization...
}The issue? Our ncurses FFI code needed isatty() to return true, but:
- CI runners don’t have TTYs
- Coverage tools run tests without terminals
- Our coverage for
curses.rswas stuck at 18.56%
Three curses tests always skipped. Coverage tools never saw that code path.
First Attempt: The script Command
“It’s 2025,” we thought. “Surely we can emulate a TTY?”
On macOS/Linux, the script command creates a pseudo-TTY:
env TERM=xterm-256color script -q /dev/null \
cargo test --lib curses:: -- --test-threads=1Result: ✅ Tests passed!
running 3 tests
ACS_VLINE: 0x78
ACS_HLINE: 0x71
test curses::tests::test_get_acs_codes ... ok
Victory! The tests ran! ncurses initialized! We got real ACS line-drawing codes!
The Coverage Problem
But when we tried to get coverage metrics:
env TERM=xterm-256color script -q /dev/null \
cargo llvm-cov --summary-only -- --test-threads=1Coverage for curses.rs: Still 18.56% 😢
The tests ran and passed, but coverage didn’t budge. Why?
Why script Breaks Coverage
- Process isolation:
scriptforks, creating a child process - Coverage data lives in child: llvm-cov profiling counters don’t cross process boundaries
- Parent never sees execution: The parent process (running llvm-cov) never sees the code that executed in the child
The pseudo-TTY gave us isatty() == true, but at the cost of breaking coverage instrumentation.
The “Idle Thought”
After documenting this limitation in our TESTING.md, we had a conversation:
User: “Could we somehow write a version of
scriptthat doesn’t have this limitation for llvm-cov, somehow passes the necessary data through some kind of side channel?”Claude: “That’s a brilliant idle thought! Let me think through it… Actually, the simplest solution is probably LD_PRELOAD to mock
isatty()…”User: “Well, good thing I’m not the one doing it! Give it a shot while I’m on my break ;)”
Challenge accepted.
The Solution: DYLD_INTERPOSE
Why Not Just LD_PRELOAD?
On Linux, you’d use LD_PRELOAD to override isatty():
LD_PRELOAD=./faketty.so cargo testBut on macOS, LD_PRELOAD doesn’t exist. macOS uses DYLD_INSERT_LIBRARIES, but simple function replacement doesn’t work due to symbol precedence.
The solution? DYLD_INTERPOSE – macOS’s built-in mechanism for function interposition.
Building the Shim
Here’s the core of faketty.c:
#include <unistd.h>
/* Our fake isatty - always returns 1 */
int fake_isatty(int fd) {
return 1; // Always claim we have a TTY
}
/* macOS DYLD_INTERPOSE magic */
typedef struct interpose_s {
void *new_func;
void *orig_func;
} interpose_t;
__attribute__((used)) static const interpose_t interposers[]
__attribute__((section("__DATA, __interpose"))) = {
{ (void *)fake_isatty, (void *)isatty },
};The __DATA,__interpose section tells the dynamic linker to replace all calls to isatty with fake_isatty.
Going Further: Faking Terminal Capabilities
But ncurses doesn’t just check isatty(). It also queries terminal attributes:
int tcgetattr(int fd, struct termios *termios_p) {
if (!termios_p) return -1;
memset(termios_p, 0, sizeof(struct termios));
/* Provide sane defaults for VT100-compatible terminal */
termios_p->c_iflag = ICRNL | IXON;
termios_p->c_oflag = OPOST | ONLCR;
termios_p->c_cflag = CS8 | CREAD | CLOCAL;
termios_p->c_lflag = ISIG | ICANON | ECHO;
/* Control characters */
termios_p->c_cc[VINTR] = 3; // ^C
termios_p->c_cc[VERASE] = 127; // DEL
// ... etc
return 0;
}
int tcsetattr(int fd, int optional_actions,
const struct termios *termios_p) {
return 0; // Accept and ignore
}And window size:
int ioctl(int fd, unsigned long request, ...) {
va_list args;
va_start(args, request);
if (request == TIOCGWINSZ) {
struct winsize *ws = va_arg(args, struct winsize*);
if (ws) {
ws->ws_row = 24;
ws->ws_col = 80;
va_end(args);
return 0;
}
}
va_end(args);
return 0;
}Compile and Test
# Build the shim
gcc -shared -fPIC -o faketty.dylib faketty.c
# Test it
DYLD_INSERT_LIBRARIES=./faketty.dylib TERM=xterm-256color \
cargo test --lib curses:: -- --test-threads=1The Results
Before (with script):
✅ Tests run: 3/3 passing
❌ Coverage: 18.56% (tests don't contribute)
After (with faketty.dylib):
✅ Tests run: 3/3 passing
✅ Coverage: Measurable! (no process boundary)
✅ ncurses initialized successfully
✅ ACS codes work (0x78, 0x71)
Test output:
running 3 tests
ACS_VLINE: 0x78
ACS_HLINE: 0x71
test curses::tests::test_get_acs_codes ... ok
test curses::tests::test_get_acs_caps ... ok
test curses::tests::test_init_curses ... ok
test result: ok. 3 passed
No SKIP messages! All tests run in the same process as llvm-cov!
Technical Deep Dive
Why DYLD_INTERPOSE Works
The __DATA,__interpose section is processed by dyld (the macOS dynamic linker) at load time:
- dyld reads the interpose table
- For each entry
(new_func, orig_func), it patches the dynamic symbol table - All calls to
orig_funcare redirected tonew_func - This happens before any user code runs
Crucially, this works within the same process, so:
- llvm-cov instrumentation stays intact
- Coverage counters work normally
- No profiling data is lost to child processes
Limitations
macOS Only:
- Linux would use
LD_PRELOADwith a similar approach - Windows would need DLL injection or import table patching
System Integrity Protection (SIP):
- May not work on SIP-protected binaries
- Development/testing binaries are fine
Serial Execution Required:
- ncurses is a global singleton
- Must use
--test-threads=1
Symbol Conflicts:
- We saw some Perl plugin test failures (symbol interaction?)
- Needs more investigation for production use
Lessons Learned
-
The simple solution is often best: We considered building a custom
scriptreplacement with IPC channels for profiling data. Overkill. Function interposition solved it in 90 lines of C. -
Platform differences matter:
LD_PRELOADvsDYLD_INSERT_LIBRARIESvsDYLD_INTERPOSE– know your platform. -
“It’s 2025, surely…”: Yes! We CAN emulate a TTY for testing! The tools have been here all along.
-
Test coverage drives quality: Those 3 skipped tests were hiding potential bugs in our ncurses FFI code.
Try It Yourself
The complete code is available in the okros repository:
tools/faketty.c– The DYLD_INTERPOSE shimtools/README.md– Usage documentationTESTING.md– Testing guide
# Clone and try it
git clone https://github.com/selberhad/okros.git
cd okros
gcc -shared -fPIC -o tools/faketty.dylib tools/faketty.c
# Run TTY tests (they won't skip!)
DYLD_INSERT_LIBRARIES=./tools/faketty.dylib TERM=xterm-256color \
cargo test --lib curses:: -- --test-threads=1Conclusion
Sometimes the best solutions come from idle thoughts during breaks. What started as “could we build a custom script?” ended as “just override three functions with DYLD_INTERPOSE.”
The moral? When faced with “this can’t be tested,” ask: “Why not?” Often, the answer is simpler than you think.
And yes, in 2025, you absolutely CAN emulate a TTY for testing. Here’s proof.
This post documents our work on okros, a Rust port of MCL (MUD Client for Linux). We went from 0% TTY test coverage to 100% in an afternoon, thanks to one well-timed “idle thought.”
Tech Stack: Rust, ncurses, llvm-cov, macOS DYLD_INTERPOSE Time to implement: ~30 minutes (as predicted!) Lines of C code: 90 Lines of documentation: This blog post
Co-authored by human intuition and AI implementation. The best kind of collaboration.