Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ $(BUILD_DIR)/test-teardown-live-vcpu-host: \
@echo " LD $@"
$(Q)$(CC) $(CFLAGS) -o $@ $^ $(HVF_LDFLAGS)

## Build the buffered GDB session host regression
$(BUILD_DIR)/test-gdbstub-host: $(BUILD_DIR)/test-gdbstub-host.o \
$(BUILD_DIR)/debug/gdbstub-reg.o | $(BUILD_DIR)
@echo " LD $@"
$(Q)$(CC) $(CFLAGS) -o $@ $^ $(HVF_LDFLAGS)

## Build the proved/gva.h contract-check host test (native macOS binary)
# Header-only: proved/gva.h is static inline, so the test links nothing
# from the project. It skips unless the build defines ELFUSE_CONTRACT_ASSERT,
Expand Down
3 changes: 2 additions & 1 deletion mk/config.mk
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ NATIVE_TESTS := tests/test-multi-vcpu.c tests/test-rwx.c \
tests/test-stdio-nonblock-host.c \
tests/test-guest-env-host.c \
tests/test-usb-desc-host.c \
tests/test-elf-headers-host.c
tests/test-elf-headers-host.c \
tests/test-gdbstub-host.c
SPECIAL_TEST_SRCS := tests/test-lowbase-mem.c
SPECIAL_TEST_BINS := $(BUILD_DIR)/test-lowbase-mem-200000 $(BUILD_DIR)/test-lowbase-mem-300000

Expand Down
12 changes: 9 additions & 3 deletions mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n
test-sysroot-tmp-remove test-sysroot-host-fallback test-sysroot-case-exact \
test-sysroot-create-paths test-fork-ipc-protocol-host \
test-vcpu-run-hooks-host test-identity-override-host \
test-dynamic-array-host test-string-builder-host \
test-dynamic-array-host test-string-builder-host test-gdbstub-host \
test-config \
test-mremap-tail-emfile \
test-proctitle-host test-proctitle-low-stack \
Expand Down Expand Up @@ -235,7 +235,7 @@ CHECK_HOST_UNIT_BINS := $(addprefix $(BUILD_DIR)/, \
test-casefold-walk-host test-absock-names-host \
test-dynamic-array-host test-string-builder-host \
test-wakeup-pipe-host test-guest-env-host \
test-usb-desc-host test-elf-headers-host)
test-usb-desc-host test-elf-headers-host test-gdbstub-host)

# Lanes shared by check and check-sanitizer, in execution order: the host
# unit binaries, then the name-contract lanes cheap enough for a sanitizer
Expand All @@ -257,6 +257,7 @@ $(call run-host-unit,test-stdio-nonblock-host,launcher stdio flags across a gues
$(call run-host-unit,test-guest-env-host,guest environment merge cross product)
$(call run-host-unit,test-usb-desc-host,USB descriptor blob walk unit test)
$(call run-host-unit,test-elf-headers-host,ELF header validation unit test)
$(call run-host-unit,test-gdbstub-host,buffered GDB session regression)
$(call run-lane,test-usb-sysfs,synthetic USB tree contract)
$(call run-lane,test-usb-sysfs-sysroot,synthetic USB /sys sharing a populated sysroot)
$(call run-lane,test-usb-sysfs-matrix,every /sys and /dev/bus entry point against every path class)
Expand Down Expand Up @@ -1147,8 +1148,13 @@ test-launch-flags: $(ELFUSE_BIN) $(TEST_HELLO_DEP) $(TEST_ENV_DEPS)
test-usage-synopsis: $(ELFUSE_BIN)
@bash tests/test-usage-synopsis.sh $(ELFUSE_BIN)

## Run the buffered GDB session host regression
test-gdbstub-host: $(BUILD_DIR)/test-gdbstub-host
$(BUILD_DIR)/test-gdbstub-host

## Run GDB stub integration tests (LLDB <-> elfuse gdbstub)
test-gdbstub: $(ELFUSE_BIN) $(TEST_DIR)/test-hello
test-gdbstub: $(ELFUSE_BIN) $(TEST_DIR)/test-hello $(BUILD_DIR)/test-gdbstub-host
$(call run-host-unit,test-gdbstub-host,buffered GDB session regression)
@bash tests/test-gdbstub.sh -e $(ELFUSE_BIN) -v

## Run Rosetta CLI gating regressions without requiring Rosetta runtime support
Expand Down
9 changes: 7 additions & 2 deletions src/debug/gdbstub-rsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ void gdb_rsp_reset(gdb_rsp_ctx_t *ctx)
ctx->no_ack_mode = false;
}

bool gdb_rsp_pending(const gdb_rsp_ctx_t *ctx)
{
return ctx->read_pos < ctx->read_len;
}

void gdb_rsp_set_noack(gdb_rsp_ctx_t *ctx, bool enabled)
{
ctx->no_ack_mode = enabled;
Expand Down Expand Up @@ -299,7 +304,7 @@ int gdb_rsp_recv(gdb_rsp_ctx_t *ctx, int fd, char *buf, size_t bufsz)
bool overflow = false;

while (1) {
if (ctx->read_pos >= ctx->read_len) {
if (!gdb_rsp_pending(ctx)) {
ssize_t n = read(fd, ctx->read_buf, GDB_RSP_READ_BUF_SIZE);
if (n <= 0) {
if (n < 0 && errno == EINTR)
Expand All @@ -310,7 +315,7 @@ int gdb_rsp_recv(gdb_rsp_ctx_t *ctx, int fd, char *buf, size_t bufsz)
ctx->read_len = (size_t) n;
}

while (ctx->read_pos < ctx->read_len) {
while (gdb_rsp_pending(ctx)) {
uint8_t c = ctx->read_buf[ctx->read_pos++];

if (state == 0 && (c == '+' || c == '-'))
Expand Down
2 changes: 2 additions & 0 deletions src/debug/gdbstub-rsp.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ uint64_t gdb_parse_hex(const char **pp);

int gdb_rsp_send(int fd, const char *data, size_t len);
void gdb_rsp_reset(gdb_rsp_ctx_t *ctx);
/* Unconsumed bytes may contain acknowledgments or an incomplete packet. */
bool gdb_rsp_pending(const gdb_rsp_ctx_t *ctx);
void gdb_rsp_set_noack(gdb_rsp_ctx_t *ctx, bool enabled);
int gdb_rsp_recv(gdb_rsp_ctx_t *ctx, int fd, char *buf, size_t bufsz);
32 changes: 17 additions & 15 deletions src/debug/gdbstub.c
Original file line number Diff line number Diff line change
Expand Up @@ -1143,23 +1143,25 @@ static void gdb_client_session(void)
gdb.rsp_ctx = &rsp_ctx;

while (gdb.client_fd >= 0) {
/* Wait for either a packet from GDB or a stop event from a vCPU. Use
* poll() so the GDB stub can wake up when a thread stops.
/* A socket read can buffer several packets. Consume them before waiting
* for more input that the client may never send.
*/
struct pollfd pfd = {
.fd = gdb.client_fd,
.events = POLLIN,
};

int pr = poll(&pfd, 1, -1);
if (pr <= 0) {
if (pr < 0 && errno == EINTR)
continue;
break;
}
if (!gdb_rsp_pending(&rsp_ctx)) {
struct pollfd pfd = {
.fd = gdb.client_fd,
.events = POLLIN,
};

int pr = poll(&pfd, 1, -1);
if (pr <= 0) {
if (pr < 0 && errno == EINTR)
continue;
break;
}

if (pfd.revents & (POLLERR | POLLHUP))
break;
if (pfd.revents & (POLLERR | POLLHUP))
break;
}

int pkt_len =
gdb_rsp_recv(&rsp_ctx, gdb.client_fd, pkt_buf, GDB_PKT_BUF_SIZE);
Expand Down
164 changes: 164 additions & 0 deletions tests/test-gdbstub-host.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2026 elfuse contributors
* SPDX-License-Identifier: Apache-2.0
*/

#include <poll.h>
#include <sys/ioctl.h>
#include <sys/socket.h>

#include "host-test-util.h"
#include "utils.h"

static const char requests[] = "$qAttached#8f$qAttached#8f";
static const char replies[] = "+$1#31+$1#31";
static int server_fd;
static int read_calls;
static int poll_calls;

static void require(bool ok, const char *detail)
{
host_check(ok, "buffered session", detail);
if (!ok)
exit(EXIT_FAILURE);
}

static ssize_t batch_read(int fd, void *buf, size_t count)
{
require(fd == server_fd && ++read_calls == 1,
"only one transport read is needed");
require(count >= sizeof(requests) - 1, "the read buffer fits both packets");

/* Return both packets together even when the socket returns short reads. */
size_t used = 0;
while (used < sizeof(requests) - 1) {
ssize_t n = read(fd, (char *) buf + used, sizeof(requests) - 1 - used);
if (n < 0 && errno == EINTR)
continue;
require(n > 0, "the queued request bytes are readable");
used += (size_t) n;
}
int queued = -1;
require(ioctl(fd, FIONREAD, &queued) == 0 && queued == 0,
"the socket is empty after the transport read");
return (ssize_t) used;
}

static int session_poll(struct pollfd *fds, nfds_t count, int timeout)
{
require(count == 1 && fds[0].fd == server_fd && fds[0].events == POLLIN &&
timeout == -1,
"the session polls its client socket");
poll_calls++;

/* Keep real readiness results, but return immediately at an empty socket.
*/
int ready = poll(fds, count, 0);
require(
poll_calls == 1 ? ready == 1 && fds[0].revents == POLLIN : ready == 0,
"only the initial poll finds socket input");
return ready;
}

/* Compile the real session and transport with only their I/O calls wrapped. */
#define read batch_read
#include "../src/debug/gdbstub-rsp.c"
#undef read
#define poll session_poll
#include "../src/debug/gdbstub.c"
#undef poll

/* No guest or vCPU operation belongs to a qAttached exchange. */
static _Noreturn void unexpected_call(const char *name)
{
host_fail("unexpected dependency", name);
exit(EXIT_FAILURE);
}

_Thread_local thread_entry_t *current_thread;

thread_entry_t *thread_find(int64_t tid)
{
unexpected_call(__func__);
}

bool thread_tid_alive(int64_t tid)
{
unexpected_call(__func__);
}

void thread_for_each(void (*fn)(thread_entry_t *t, void *ctx), void *ctx)
{
unexpected_call(__func__);
}

void thread_interrupt_all(void)
{
unexpected_call(__func__);
}

void *guest_ptr_bound(const guest_t *g,
uint64_t gva,
uint64_t *avail,
int required_perms,
uint64_t len_limit)
{
unexpected_call(__func__);
}

int guest_read(const guest_t *g, uint64_t gva, void *dst, size_t len)
{
unexpected_call(__func__);
}

int guest_write(guest_t *g, uint64_t gva, const void *src, size_t len)
{
unexpected_call(__func__);
}

void log_impl(int level, const char *file, int line, const char *fmt, ...)
{
unexpected_call(__func__);
}

int main(void)
{
int sockets[2];
require(socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0, "socketpair");
server_fd = sockets[0];
require(fcntl(server_fd, F_SETFL, O_NONBLOCK) == 0 &&
fcntl(sockets[1], F_SETFL, O_NONBLOCK) == 0,
"unexpected socket reads must not block");
require(write_all(sockets[1], requests, sizeof(requests) - 1) == 0,
"queue both requests before entering the session");

gdb.client_fd = server_fd;
gdb_client_session();

require(read_calls == 1, "both requests use one transport read");
require(poll_calls == 2, "poll only before input and after draining it");
require(gdb.rsp_ctx == NULL, "the session clears its transport pointer");

int queued = -1;
require(ioctl(server_fd, FIONREAD, &queued) == 0 && queued == 0,
"no further client input wakes the session");
require(ioctl(sockets[1], FIONREAD, &queued) == 0 &&
queued == sizeof(replies) - 1,
"both replies arrived before further client input");
char received[sizeof(replies) - 1];
size_t used = 0;
while (used < sizeof(received)) {
ssize_t n = read(sockets[1], received + used, sizeof(received) - used);
if (n < 0 && errno == EINTR)
continue;
require(n > 0, "read the queued replies");
used += (size_t) n;
}
require(memcmp(received, replies, sizeof(received)) == 0,
"both requests produce their ACK and checksummed reply");

close(sockets[0]);
close(sockets[1]);
gdb.client_fd = -1;
return host_summary("test-gdbstub-host");
}
38 changes: 33 additions & 5 deletions tests/test-gdbstub.sh
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,23 @@ run_lldb()
run_raw_rsp_script()
{
local timeout_sec="${RAW_RSP_TIMEOUT:-10}"
RAW_RSP_RC=0
RAW_RSP_OUT=$(
timeout "$timeout_sec" python3 - "$GDB_PORT" 2>&1 << 'PY'
timeout "$timeout_sec" python3 - "$GDB_PORT" "${1:-single}" 2>&1 << 'PY'
import socket
import sys

port = int(sys.argv[1])
sock = socket.create_connection(("127.0.0.1", port), timeout=5)
sock.settimeout(5)

def send_packet(payload: str) -> None:
def frame_packet(payload: str) -> bytes:
data = payload.encode("ascii")
cksum = sum(data) & 0xFF
sock.sendall(b"$" + data + b"#" + f"{cksum:02x}".encode("ascii"))
return b"$" + data + b"#" + f"{cksum:02x}".encode("ascii")

def send_packet(payload: str) -> None:
sock.sendall(frame_packet(payload))

def recv_exact(n: int) -> bytes:
buf = b""
Expand All @@ -187,6 +191,8 @@ def recv_packet():
break
body.extend(ch)
cksum = recv_exact(2).decode("ascii")
if cksum != f"{sum(body) & 0xFF:02x}":
raise RuntimeError("invalid reply checksum")
return ack, body.decode("ascii"), cksum

send_packet("qSupported")
Expand All @@ -207,9 +213,19 @@ print(f"stop_ack={ack3 or 'none'}")
print(f"stop_body={body3}")
print(f"stop_cksum={cksum3}")

if sys.argv[2] == "batch":
# This loopback write relies on coalescing; short reads can hide the stall.
# Send nothing else until all eight replies arrive.
sock.sendall(frame_packet("qAttached") * 8)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing forces the stub to coalesce these eight frames into one read(). If they arrive split, the unfixed loop polls, finds data, and answers all eight, so the guard passes against the bug it exists to catch. 112 bytes in one loopback write makes that unlikely, but the test does not say it is relying on that.

for _ in range(8):
ack, body, _ = recv_packet()
if ack or body != "1":
raise RuntimeError(f"unexpected batched reply: {ack!r} {body!r}")
print("batched_replies=8")

sock.close()
PY
) || true
) || RAW_RSP_RC=$?
}

report()
Expand Down Expand Up @@ -507,7 +523,8 @@ stop_elfuse
start_elfuse "$GUEST"
run_raw_rsp_script
ok=0
if echo "$RAW_RSP_OUT" | grep -q "qSupported_ack=+" \
if [ "$RAW_RSP_RC" -eq 0 ] \
&& echo "$RAW_RSP_OUT" | grep -q "qSupported_ack=+" \
&& echo "$RAW_RSP_OUT" | grep -q "qSupported_body=.*QStartNoAckMode+" \
&& echo "$RAW_RSP_OUT" | grep -q "noack_ack=+" \
&& echo "$RAW_RSP_OUT" | grep -q "noack_body=OK" \
Expand All @@ -518,6 +535,17 @@ fi
report "QStartNoAckMode: final ack then packet-only replies" $ok
stop_elfuse

# Test 18: Requests buffered by the transport must not wait for socket input.
start_elfuse "$GUEST"
run_raw_rsp_script batch
ok=0
if [ "$RAW_RSP_RC" -eq 0 ] \
&& echo "$RAW_RSP_OUT" | grep -q "^batched_replies=8$"; then
ok=1
fi
report "buffered packets: reply without further socket input" $ok
stop_elfuse

# Summary
echo ""
if [ "$fails" -eq 0 ]; then
Expand Down
Loading