Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ export const sigactionLayout: () =>
sizeof: number;
} = $newZigFunction("sys.zig", "TestingAPIs.sigactionLayout", 0);

export const termiosLayout: () =>
| undefined
| {
installed: { cc_lnext: number; echo: boolean };
readback: { cc_lnext: number; echo: boolean };
sizeof: number;
} = $newZigFunction("sys.zig", "TestingAPIs.termiosLayout", 0);

export const stringsInternals = {
/**
* Calls `bun.strings.toUTF16AllocForReal(allocator, bytes, false, true)` and
Expand Down
4 changes: 2 additions & 2 deletions src/md/ansi_renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2086,9 +2086,9 @@ fn probeKittyGraphics() bool {
// restoring to a fixed .normal would corrupt it — instead reapply
// exactly what we read. tcgetattr failing means stdin isn't a real
// TTY in a way we can snapshot; skip probing entirely.
const saved_termios = std.posix.tcgetattr(0) catch return false;
const saved_termios = bun.sys.tcgetattr(0) catch return false;
_ = bun.tty.setMode(0, .raw);
defer std.posix.tcsetattr(0, .NOW, saved_termios) catch {
defer bun.sys.tcsetattr(0, .NOW, saved_termios) catch {
_ = bun.tty.setMode(0, .normal);
};

Expand Down
21 changes: 13 additions & 8 deletions src/runtime/api/bun/Terminal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ fn createPtyPosix(cols: u16, rows: u16) CreatePtyError!PtyResult {

// Configure sensible terminal defaults matching node-pty behavior.
// These are "cooked mode" defaults that most terminal applications expect.
if (std.posix.tcgetattr(slave_fd)) |termios| {
if (bun.sys.tcgetattr(slave_fd)) |termios| {
var t = termios;

// Input flags: standard terminal input processing
Expand Down Expand Up @@ -566,11 +566,16 @@ fn createPtyPosix(cols: u16, rows: u16) CreatePtyError!PtyResult {
t.cc[@intFromEnum(std.posix.V.MIN)] = 1; // Min chars for non-canonical read
t.cc[@intFromEnum(std.posix.V.TIME)] = 0; // Timeout for non-canonical read

// Set baud rate to 38400 (standard for PTYs)
t.ispeed = .B38400;
t.ospeed = .B38400;
// Set baud rate to 38400 (standard for PTYs). bionic's termios has
// no `ispeed`/`ospeed` — baud lives in the CBAUD bits of `c_cflag`
// instead — so this write is a no-op there. It's a PTY, so the
// baud rate is advisory only.
if (comptime @hasField(@TypeOf(t), "ispeed")) {
t.ispeed = .B38400;
t.ospeed = .B38400;
}

std.posix.tcsetattr(slave_fd, .NOW, t) catch {};
bun.sys.tcsetattr(slave_fd, .NOW, t) catch {};
} else |err| {
// tcgetattr failed, log in debug builds but continue without modifying termios
if (comptime bun.Environment.allow_assert) {
Expand Down Expand Up @@ -941,18 +946,18 @@ pub fn setRawMode(
return .js_undefined;
}
/// POSIX termios struct for terminal flags manipulation
const Termios = if (Environment.isPosix) std.posix.termios else void;
const Termios = if (Environment.isPosix) bun.sys.termios else void;

/// Get terminal attributes using tcgetattr
fn getTermios(fd: bun.FD) ?Termios {
if (comptime !Environment.isPosix) return null;
return std.posix.tcgetattr(fd.cast()) catch null;
return bun.sys.tcgetattr(fd.cast()) catch null;
}

/// Set terminal attributes using tcsetattr (TCSANOW = immediate)
fn setTermios(fd: bun.FD, termios_p: *const Termios) bool {
if (comptime !Environment.isPosix) return false;
std.posix.tcsetattr(fd.cast(), .NOW, termios_p.*) catch return false;
bun.sys.tcsetattr(fd.cast(), .NOW, termios_p.*) catch return false;
return true;
}

Expand Down
67 changes: 67 additions & 0 deletions src/sys/sys.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2292,6 +2292,73 @@ pub fn sigaction(sig: u8, noalias act: ?*const Sigaction, noalias oact: ?*Sigact
_ = libc_sigaction(sig, act, oact);
}

/// bionic's `struct termios` is the raw kernel `asm-generic/termbits.h` shape
/// (`NCCS == 19`, no trailing `c_ispeed`/`c_ospeed`; baud lives in the
/// `c_cflag` CBAUD bits). `std.c.termios` for `.linux` assumes the glibc/musl
/// shape (`NCCS == 32` plus `c_ispeed`/`c_ospeed`, ~60B vs bionic's 36B). The
/// first 36 bytes are layout-compatible so `tcgetattr`/`tcsetattr` appear to
/// work, but writes to `.ispeed`/`.ospeed` land past bionic's struct and are
/// silently ignored, and reinitialising `c_cflag` zeroes CBAUD → baud
/// becomes B0. Until the Zig stdlib grows an `abi.isAndroid()` case, use
/// this wrapper instead of `std.posix.termios` / `std.posix.tcgetattr` /
/// `std.posix.tcsetattr`.
pub const termios = if (Environment.isAndroid) extern struct {
// bionic libc/include/bits/termios_inlines.h → uapi asm-generic/termbits.h
comptime {
// Trip when the Zig stdlib gains a bionic `termios` so this
// workaround can be dropped. bionic has no `ispeed`/`ospeed` and
// `sizeof(struct termios) == 36`; std's glibc-shaped struct is ~60B
// with trailing speed fields.
if (!@hasField(posix.termios, "ispeed") and @sizeOf(posix.termios) == @sizeOf(@This()))
@compileError("std.posix.termios now matches bionic; remove the bun.sys.termios workaround");
}

// Reuse std's flag types — the bit layouts come from the kernel UAPI
// and are identical across glibc/musl/bionic on a given architecture.
iflag: posix.tc_iflag_t,
oflag: posix.tc_oflag_t,
cflag: posix.tc_cflag_t,
lflag: posix.tc_lflag_t,
line: posix.cc_t,
cc: [19]posix.cc_t,
} else posix.termios;

pub fn tcgetattr(handle: posix.fd_t) posix.TermiosGetError!termios {
if (comptime !Environment.isAndroid) return posix.tcgetattr(handle);
// `std.c.tcgetattr`'s parameter type is the glibc-shaped `termios`; call
// libc directly with the bionic struct instead.
const libc_tcgetattr = @extern(
*const fn (posix.fd_t, *termios) callconv(.c) c_int,
.{ .name = "tcgetattr" },
);
while (true) {
var term: termios = undefined;
switch (posix.errno(libc_tcgetattr(handle, &term))) {
.SUCCESS => return term,
.INTR => continue,
.NOTTY => return error.NotATerminal,
else => |err| return posix.unexpectedErrno(err),
}
}
}

pub fn tcsetattr(handle: posix.fd_t, optional_action: posix.TCSA, termios_p: termios) posix.TermiosSetError!void {
if (comptime !Environment.isAndroid) return posix.tcsetattr(handle, optional_action, termios_p);
const libc_tcsetattr = @extern(
*const fn (posix.fd_t, posix.TCSA, *const termios) callconv(.c) c_int,
.{ .name = "tcsetattr" },
);
while (true) {
switch (posix.errno(libc_tcsetattr(handle, optional_action, &termios_p))) {
.SUCCESS => return,
.INTR => continue,
.NOTTY => return error.NotATerminal,
.IO => return error.ProcessOrphaned,
else => |err| return posix.unexpectedErrno(err),
}
}
}

pub fn ppoll(fds: []std.posix.pollfd, timeout: ?*std.posix.timespec, sigmask: ?*const std.posix.sigset_t) Maybe(usize) {
while (true) {
const rc = switch (Environment.os) {
Expand Down
59 changes: 59 additions & 0 deletions src/sys_jsc/error_jsc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,65 @@ pub const TestingAPIs = struct {
.sizeof = @as(f64, @floatFromInt(@sizeOf(bun.sys.Sigaction))),
}, globalThis)).toJS();
}

/// Verifies `bun.sys.termios`'s layout matches the host libc by
/// round-tripping distinctive values through `tcgetattr`/`tcsetattr` on a
/// freshly opened PTY slave. If the struct layout disagrees with libc
/// (as `std.posix.termios` does on Android bionic — `NCCS == 19` and no
/// `c_ispeed`/`c_ospeed`, 36B vs glibc's ~60B), libc and Zig read/write
/// `c_cc` at different extents and the values don't round-trip. Returns
/// `{ installed, readback, sizeof }` for the test to compare. POSIX-only.
pub fn termiosLayout(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (comptime !Environment.isPosix) return .js_undefined;

const posix = std.posix;
// `posix_openpt`/`grantpt`/`unlockpt`/`ptsname` are in libc on Linux
// (glibc/musl/bionic), macOS and FreeBSD, so no libutil dlopen dance
// is needed. On BSD-derived systems the *master* fd isn't a terminal
// (`tcgetattr` → ENOTTY), so open the slave and probe that instead.
const pty = struct {
const posix_openpt = @extern(*const fn (c_int) callconv(.c) c_int, .{ .name = "posix_openpt" });
const grantpt = @extern(*const fn (c_int) callconv(.c) c_int, .{ .name = "grantpt" });
const unlockpt = @extern(*const fn (c_int) callconv(.c) c_int, .{ .name = "unlockpt" });
const ptsname = @extern(*const fn (c_int) callconv(.c) ?[*:0]const u8, .{ .name = "ptsname" });
};
const master = pty.posix_openpt(bun.O.RDWR | bun.O.NOCTTY);
if (master < 0) return .js_undefined;
defer bun.FD.fromNative(master).close();
if (pty.grantpt(master) != 0 or pty.unlockpt(master) != 0) return .js_undefined;
const slave_name = pty.ptsname(master) orelse return .js_undefined;
const slave = switch (bun.sys.open(std.mem.span(slave_name), bun.O.RDWR | bun.O.NOCTTY, 0)) {
.result => |f| f,
.err => return .js_undefined,
};
defer slave.close();
const fd = slave.native();

var t = bun.sys.tcgetattr(fd) catch return .js_undefined;
// Pick a cc index near the top of bionic's 19-slot array so a size
// mismatch is more likely to be observable, plus a flag word at a
// fixed offset (lflag is at 12 on every supported target).
const probe_cc: u8 = 0x5a;
const probe_echo = !t.lflag.ECHO;
t.cc[@intFromEnum(posix.V.LNEXT)] = probe_cc;
t.lflag.ECHO = probe_echo;
bun.sys.tcsetattr(fd, .NOW, t) catch return .js_undefined;
const rb = bun.sys.tcgetattr(fd) catch return .js_undefined;

const installed = (try jsc.JSObject.create(.{
.cc_lnext = @as(f64, @floatFromInt(probe_cc)),
.echo = probe_echo,
}, globalThis)).toJS();
const readback = (try jsc.JSObject.create(.{
.cc_lnext = @as(f64, @floatFromInt(rb.cc[@intFromEnum(posix.V.LNEXT)])),
.echo = rb.lflag.ECHO,
}, globalThis)).toJS();
return (try jsc.JSObject.create(.{
.installed = installed,
.readback = readback,
.sizeof = @as(f64, @floatFromInt(@sizeOf(bun.sys.termios))),
}, globalThis)).toJS();
}
};

const std = @import("std");
Expand Down
36 changes: 36 additions & 0 deletions test/internal/termios-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// bun.sys.termios must match the host libc's `struct termios`. Zig's
// std.posix.termios assumes the glibc/musl layout on Linux (cc[32] plus
// trailing c_ispeed/c_ospeed, ~60B), which is wrong for bionic (Android
// uses the raw kernel struct: cc[19], no speed fields, 36B). The first
// 36B are layout-compatible so tcgetattr/tcsetattr "mostly work", but
// writes to .ispeed/.ospeed land past bionic's struct and reinitialising
// c_cflag zeroes CBAUD → baud becomes B0.
//
// This test opens a PTY slave via posix_openpt/grantpt/unlockpt/ptsname
// (the master fd isn't a terminal on BSD/macOS), writes a sentinel into
// c_cc[VLNEXT] and toggles ECHO via bun.sys.tcsetattr, then reads both
// back via bun.sys.tcgetattr. That round-trip holds iff the Zig struct
// agrees with libc's on this platform.
import { expect, test } from "bun:test";
import { isPosix } from "harness";

test.skipIf(!isPosix)("bun.sys.termios matches the host libc's struct termios", () => {
// Resolve lazily: a static `import { termiosLayout }` throws
// `SyntaxError: Export named 'termiosLayout' not found` at module
// load on a binary without the binding. bun:test's console output
// counts that as "1 fail", but the JUnit reporter emits zero
// testcases — so a fail-before gate that parses JUnit sees 0
// failures and concludes the test doesn't exercise the fix.
// Requiring inside the test body turns the missing export into an
// ordinary assertion failure that JUnit records. This mirrors
// sigaction-layout.test.ts.
const { termiosLayout } = require("bun:internal-for-testing") as typeof import("bun:internal-for-testing");
expect(termiosLayout).toBeFunction();

const result = termiosLayout();
expect(result).toBeDefined();
// c_cc[VLNEXT] and lflag.ECHO must survive the trip through libc.
expect(result!.readback).toEqual(result!.installed);
Comment thread
robobun marked this conversation as resolved.
expect(result!.installed.cc_lnext).toBe(0x5a);
expect(result!.sizeof).toBeGreaterThan(0);
});
Loading