diff --git a/emulation/mirage/rocjitsu/src/daemon.rs b/emulation/mirage/rocjitsu/src/daemon.rs index e029446626d2..443c5159f2cc 100644 --- a/emulation/mirage/rocjitsu/src/daemon.rs +++ b/emulation/mirage/rocjitsu/src/daemon.rs @@ -367,7 +367,8 @@ fn handle_client(fd: RawFd, shared: &Shared) { loop { let mut header = [0u8; RPC_HEADER_LEN]; - if !recv_exact(fd, &mut header) { + let (ok, mut in_fd) = recv_header_with_fd(fd, &mut header); + if !ok { break; } let (opcode, request_id, payload_bytes) = parse_header(&header); @@ -478,8 +479,15 @@ fn handle_client(fd: RawFd, shared: &Shared) { buf_size: args_bytes as usize, result: 0, shared_handle: -1, + in_handle: in_fd, }; unsafe { lib.vm_execute_as(vm, process_id, &mut cmd) }; + // The debug session adopts the notifier fd on success + // (in_handle cleared); reclaim it otherwise. + if cmd.in_handle >= 0 { + unsafe { libc::close(cmd.in_handle) }; + } + in_fd = -1; // `buf_size` is updated in place; clamp the slice // we read back to what the payload actually holds. let out_len = cmd.buf_size.min(payload.len().saturating_sub(8)); @@ -502,6 +510,12 @@ fn handle_client(fd: RawFd, shared: &Shared) { _ => false, }; + // Reclaim any notifier fd attached to a non-ioctl message (the client + // only sends one on DBG_TRAP ENABLE; this is a safety net). + if in_fd >= 0 { + unsafe { libc::close(in_fd) }; + } + if !keep_going { break; } @@ -560,6 +574,69 @@ fn recv_exact(fd: RawFd, buf: &mut [u8]) -> bool { true } +/// Receive the 16-byte RPC header, capturing an optional `SCM_RIGHTS` fd the +/// client attaches to the message (the debugger notifier pipe on DBG_TRAP +/// ENABLE). Returns `(ok, fd)`, where `fd` is `-1` if none was received. Any +/// received fd is close-on-exec and belongs to the caller, which must close it +/// if unused. +fn recv_header_with_fd(fd: RawFd, buf: &mut [u8]) -> (bool, RawFd) { + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut c_void, + iov_len: buf.len(), + }; + let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; + let cmsg_space = unsafe { libc::CMSG_SPACE(std::mem::size_of::() as u32) } as usize; + let mut cmsg_buf = vec![0u8; cmsg_space]; + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf.as_mut_ptr() as *mut c_void; + msg.msg_controllen = cmsg_space as _; + + // `MSG_CMSG_CLOEXEC` sets `FD_CLOEXEC` on any descriptor delivered as + // ancillary data, matching `rpc_recv_msg()` in the C++ daemon. Without it + // the transferred notifier would be inheritable and could leak through a + // later `exec`. + let n = loop { + let r = unsafe { libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC) }; + if r < 0 && last_errno() == libc::EINTR { + continue; + } + break r; + }; + if n <= 0 { + return (false, -1); + } + + // Extract an fd if the client attached one as SCM_RIGHTS ancillary data. + let mut recv_fd: RawFd = -1; + unsafe { + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + std::ptr::copy_nonoverlapping( + libc::CMSG_DATA(cmsg), + &mut recv_fd as *mut RawFd as *mut u8, + std::mem::size_of::(), + ); + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + } + + // Ancillary data only arrives with the first bytes; read the remainder of a + // short header with a plain recv. + let read = n as usize; + if read < buf.len() && !recv_exact(fd, &mut buf[read..]) { + if recv_fd >= 0 { + unsafe { + libc::close(recv_fd); + } + } + return (false, -1); + } + (true, recv_fd) +} + /// Write exactly `buf.len()` bytes, handling partial writes. Returns /// false on error. fn send_exact(fd: RawFd, buf: &[u8]) -> bool { diff --git a/emulation/mirage/rocjitsu_sys/src/lib.rs b/emulation/mirage/rocjitsu_sys/src/lib.rs index 22abc8e7121f..b107196b72c4 100644 --- a/emulation/mirage/rocjitsu_sys/src/lib.rs +++ b/emulation/mirage/rocjitsu_sys/src/lib.rs @@ -77,6 +77,10 @@ pub struct RjVmCmd { pub result: i32, /// `[out]` Backing handle for shareable allocations, or -1. pub shared_handle: RjHandle, + /// `[in/out]` Client-provided fd (e.g. a debugger notifier), or -1. + /// In daemon mode the VM substitutes it into DBG_TRAP ENABLE and, on + /// adoption, clears it to -1 so the caller does not close it. + pub in_handle: RjHandle, } /// Device memory mapping descriptor (`rj_vm_map_t`). @@ -483,8 +487,9 @@ mod tests { fn struct_sizes_match_c_abi() { assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::size_of::(), 16); - // rj_vm_cmd_t: u32 + (pad) + ptr + usize + i32 + i32 on 64-bit. - assert_eq!(std::mem::size_of::(), 32); + // rj_vm_cmd_t: u32 + (pad) + ptr + usize + i32 + i32 + i32 + (pad) + // on 64-bit. + assert_eq!(std::mem::size_of::(), 40); assert_eq!(RjVmMode::Daemon as i32, 2); // rj_vm_gpu_info_t — must match the 312-byte RpcGpuInfo the // daemon handshake embeds (static_assert in rpc.h). diff --git a/emulation/mirage/tests/container_e2e.rs b/emulation/mirage/tests/container_e2e.rs index a4ff3fe9d10b..04b97ce6ea64 100644 --- a/emulation/mirage/tests/container_e2e.rs +++ b/emulation/mirage/tests/container_e2e.rs @@ -267,7 +267,7 @@ fn containerized_session_writes_cid_then_cleans_up_on_stop() { // Stopping the session must remove the container + network and delete // the whole session directory. env.mirage() - .args(["session", "stop", "s-box", "-f"]) + .args(["session", "stop", "s-box"]) .assert() .success(); diff --git a/emulation/mirage/tests/e2e.rs b/emulation/mirage/tests/e2e.rs index 28451b6e9d4d..3341fb8abee2 100644 --- a/emulation/mirage/tests/e2e.rs +++ b/emulation/mirage/tests/e2e.rs @@ -195,7 +195,7 @@ fn session_start_detach_exec_then_stop() { ); // stop the session env.mirage() - .args(["session", "stop", "s1", "-f"]) + .args(["session", "stop", "s1"]) .assert() .success(); // verify the session dir is gone @@ -219,7 +219,7 @@ fn stopped_session_stays_gone_past_heartbeat() { .assert() .success(); env.mirage() - .args(["session", "stop", "s9", "-f"]) + .args(["session", "stop", "s9"]) .assert() .success(); // Wait well past the host's heartbeat interval (2s): a live orphan @@ -288,7 +288,7 @@ fn attach_to_long_running_exec_then_signal() { } assert!(ended, "exec did not end after SIGKILL"); env.mirage() - .args(["session", "stop", "s2", "-f"]) + .args(["session", "stop", "s2"]) .assert() .success(); } @@ -345,7 +345,7 @@ fn duplicate_session_id_fails() { .assert() .failure(); env.mirage() - .args(["session", "stop", "dup", "-f"]) + .args(["session", "stop", "dup"]) .assert() .success(); } diff --git a/emulation/mirage/tests/lifecycle_e2e.rs b/emulation/mirage/tests/lifecycle_e2e.rs index 41a7968750ad..950aea23fb98 100644 --- a/emulation/mirage/tests/lifecycle_e2e.rs +++ b/emulation/mirage/tests/lifecycle_e2e.rs @@ -148,7 +148,7 @@ fn signal_request_is_written_then_consumed_by_host() { assert!(ended, "exec did not end after SIGKILL"); env.mirage() - .args(["session", "stop", "s-sigfile", "-f"]) + .args(["session", "stop", "s-sigfile"]) .assert() .success(); } @@ -191,7 +191,7 @@ fn keep_true_exec_persists_after_signal_kill() { assert!(String::from_utf8_lossy(&list.stdout).contains(&eid)); env.mirage() - .args(["session", "stop", "s-keep", "-f"]) + .args(["session", "stop", "s-keep"]) .assert() .success(); } @@ -214,7 +214,7 @@ fn keep_false_exec_is_removed_after_signal_kill() { assert!(gone, "keep=false exec dir was never removed: {exec_dir:?}"); env.mirage() - .args(["session", "stop", "s-nokeep", "-f"]) + .args(["session", "stop", "s-nokeep"]) .assert() .success(); } @@ -238,7 +238,7 @@ fn keep_false_exec_is_removed_after_natural_exit() { assert!(gone, "keep=false exec dir was not removed after exit"); env.mirage() - .args(["session", "stop", "s-natural", "-f"]) + .args(["session", "stop", "s-natural"]) .assert() .success(); } @@ -263,7 +263,7 @@ fn signal_invalid_number_is_rejected() { ); env.mirage() - .args(["session", "stop", "s-bad", "-f"]) + .args(["session", "stop", "s-bad"]) .assert() .success(); } @@ -324,7 +324,7 @@ fn session_destroy_invokes_container_provider_when_state_present() { std::fs::write(session_dir.join("node/1/cid"), "cid-1").unwrap(); env.mirage() - .args(["session", "stop", "s-cont", "-f"]) + .args(["session", "stop", "s-cont"]) .assert() .success(); @@ -381,7 +381,7 @@ fn session_destroy_skips_provider_when_no_container_state() { // No container.json is written → the provider must not run. env.mirage() - .args(["session", "stop", "s-no-cid", "-f"]) + .args(["session", "stop", "s-no-cid"]) .assert() .success(); assert!( diff --git a/emulation/mirage/tests/matrix_e2e.rs b/emulation/mirage/tests/matrix_e2e.rs index 21367f0df54a..254e59c13e09 100644 --- a/emulation/mirage/tests/matrix_e2e.rs +++ b/emulation/mirage/tests/matrix_e2e.rs @@ -427,7 +427,7 @@ fn run_combo(c: &Combo, caps: &Caps) -> Outcome { create.args(["--num-nodes", "2"]); } if c.container.is_containerized() { - create.args(["--image", "img:latest", "--provider"]); + create.args(["--image", "img:latest", "--container-provider"]); create.arg(&env.provider); } create.assert().success(); diff --git a/emulation/rocjitsu/cmake/rj_log.cmake b/emulation/rocjitsu/cmake/rj_log.cmake index c1e2570b1888..b54970057f73 100644 --- a/emulation/rocjitsu/cmake/rj_log.cmake +++ b/emulation/rocjitsu/cmake/rj_log.cmake @@ -7,6 +7,7 @@ # cmake -DRJ_LOG_GROUPS=VM → group 0 only # cmake -DRJ_LOG_GROUPS=CP → group 1 only # cmake -DRJ_LOG_GROUPS=DBT_HOOKS → group 2 only +# cmake -DRJ_LOG_GROUPS=DRIVER → group 3 only # cmake -DRJ_LOG_GROUPS=ALL → all groups # cmake -DRJ_LOG_GROUPS=0x3 → raw bitmask (groups 0 and 1) # cmake -DRJ_LOG_GROUPS=OFF → all logging off (default) @@ -19,27 +20,27 @@ # VM (bit 0) Kernel dispatch, instruction execution, memory access. # CP (bit 1) Command processor: doorbell, dispatch, completion. # DBT_HOOKS (bit 2) ROCR HSA tools DBT hook tracing. +# DRIVER (bit 3) KFD driver: ioctl dispatch, process/session state. # # Usage: include(rj_log) after project(). set(RJ_LOG_GROUPS "OFF" CACHE STRING - "Log groups: OFF, ALL, or comma-separated names (VM, CP, DBT_HOOKS). Raw bitmask also accepted." + "Log groups: OFF, ALL, or comma-separated names (VM, CP, DBT_HOOKS, DRIVER). Raw bitmask also accepted." ) # --- Group name → bit mapping (keep in sync with Logger::Group in log.h) --- set(_RJ_GROUP_VM 1) # bit 0 set(_RJ_GROUP_CP 2) # bit 1 set(_RJ_GROUP_DBT_HOOKS 4) # bit 2 -# Future groups: -# set(_RJ_GROUP_KFD 8) # bit 3 +set(_RJ_GROUP_DRIVER 8) # bit 3 # All known group bits OR'd together. math( EXPR _RJ_GROUP_ALL - "${_RJ_GROUP_VM} | ${_RJ_GROUP_CP} | ${_RJ_GROUP_DBT_HOOKS}" + "${_RJ_GROUP_VM} | ${_RJ_GROUP_CP} | ${_RJ_GROUP_DBT_HOOKS} | ${_RJ_GROUP_DRIVER}" ) # --- Resolve the user value to a numeric bitmask --- @@ -67,7 +68,7 @@ else() else() message( FATAL_ERROR - "Unknown log group '${_group}'. Known groups: VM, CP, DBT_HOOKS. Use OFF or ALL for all." + "Unknown log group '${_group}'. Known groups: VM, CP, DBT_HOOKS, DRIVER. Use OFF or ALL for all." ) endif() endforeach() diff --git a/emulation/rocjitsu/lib/rocjitsu/include/rocjitsu/vm/rj_vm.h b/emulation/rocjitsu/lib/rocjitsu/include/rocjitsu/vm/rj_vm.h index fda295193e24..eb2db1954d5a 100644 --- a/emulation/rocjitsu/lib/rocjitsu/include/rocjitsu/vm/rj_vm.h +++ b/emulation/rocjitsu/lib/rocjitsu/include/rocjitsu/vm/rj_vm.h @@ -59,6 +59,7 @@ typedef struct rj_vm_cmd_t { size_t buf_size; ///< Total size of the arguments buffer. int32_t result; ///< [out] Return code (0 on success, negative errno on failure). rj_handle_t shared_handle; ///< [out] Backing handle for shareable allocations, or -1. + rj_handle_t in_handle; ///< [in,out] Client-provided fd (e.g. debugger notifier), or -1. } rj_vm_cmd_t; /// @brief Device memory mapping descriptor. @@ -223,6 +224,14 @@ RJ_API_EXPORT rj_status_t rj_vm_restore_checkpoint(const char *path, rj_vm_t **v RJ_API_EXPORT rj_status_t rj_vm_execute(rj_vm_t *vm, rj_vm_cmd_t *cmd); /// @brief Execute a device command for a specific process (daemon mode). +/// +/// @details In daemon mode, if @p cmd carries a client-provided input fd in +/// cmd->in_handle (e.g. the debugger's notifier pipe for AMDKFD_IOC_DBG_TRAP +/// ENABLE), the VM substitutes it into the command payload and, on success, +/// adopts it — clearing cmd->in_handle to -1 so the caller does not close the +/// descriptor. If the command does not consume the fd, cmd->in_handle is left +/// unchanged for the caller to reclaim. Set cmd->in_handle to -1 when there is +/// no fd to transfer. /// @param[in] vm VM handle. /// @param[in] process_id The target process ID. /// @param[in,out] cmd Command descriptor. diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp index c0f40376c60c..691cd72882ee 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp @@ -1032,8 +1032,8 @@ std::string GuestKfd::topology_path() const { } int GuestKfd::reject_guest_execution_ioctl(unsigned long request, void *) const { - util::Logger::debug_print("rocjitsu guest gpu: rejecting ", LinuxKfd::ioctl_name(request), - " for guest gpu_id=", guest_.gpu_id); + util::Logger::driver("rocjitsu guest gpu: rejecting ", LinuxKfd::ioctl_name(request), + " for guest gpu_id=", guest_.gpu_id); errno = ENODEV; return -1; } diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/kfd_process.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/kfd_process.h index 407ff4bc7056..88c8bb7c66ac 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/kfd_process.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/kfd_process.h @@ -24,8 +24,47 @@ #include #include +#include // pid_t +#include + namespace rocjitsu { +/// @brief Move-only RAII owner of a file descriptor. +/// +/// @details Closes the descriptor on destruction, reset, or move-assignment. +/// Used where a KfdProcess must deterministically release a descriptor it owns +/// — e.g. the daemon-mode debugger notifier the transport dup'd into our fd +/// table via SCM_RIGHTS. A -1 descriptor owns nothing. +class UniqueFd { +public: + UniqueFd() = default; + explicit UniqueFd(int fd) : fd_(fd) {} + UniqueFd(UniqueFd &&other) noexcept : fd_(other.fd_) { other.fd_ = -1; } + UniqueFd &operator=(UniqueFd &&other) noexcept { + if (this != &other) { + reset(other.fd_); + other.fd_ = -1; + } + return *this; + } + UniqueFd(const UniqueFd &) = delete; + UniqueFd &operator=(const UniqueFd &) = delete; + ~UniqueFd() { reset(); } + + /// @brief The owned descriptor, or -1 if none. + [[nodiscard]] int get() const { return fd_; } + + /// @brief Close the current descriptor and take ownership of @p fd (none by default). + void reset(int fd = -1) { + if (fd_ >= 0 && fd_ != fd) + ::close(fd_); + fd_ = fd; + } + +private: + int fd_ = -1; +}; + /// @brief Per-process KFD state. /// /// @details Mirrors the kernel's kfd_process + kfd_process_device for a @@ -118,6 +157,53 @@ class KfdProcess { uint64_t r_debug = 0; }; + /// @brief Per-process debugger session state. + /// + /// @details Mirrors the debug-related fields the kernel maintains on + /// @c struct @c kfd_process in + /// @c drivers/gpu/drm/amd/amdkfd/kfd_priv.h. + /// Managed by the @c AMDKFD_IOC_DBG_TRAP ioctl handler + /// (@c kfd_ioctl_set_debug_trap in the real driver). + /// + /// Field mapping to @c kfd_priv.h: + /// | DebugSession field | kfd_process field | + /// |------------------------|-------------------------------------------------| + /// | @ref enabled | @c bool @c debug_trap_enabled | + /// | @ref runtime_state | @c kfd_runtime_info @c runtime_info.runtime_state (kfd_ioctl.h) | + /// | @ref exception_enable_mask | @c uint64_t @c exception_enable_mask | + /// | @ref dbg_fd | @c struct @c file* @c dbg_ev_file (flattened to fd) | + /// | @ref debugger_pid | @c struct @c kfd_process* @c debugger_process (stored as pid) | + struct DebugSession { + /// @brief Mirrors @c kfd_process::debug_trap_enabled. + /// Set when the device process is debug-attached with a reserved VMID. + bool enabled = false; + + /// @brief Mirrors @c kfd_process::runtime_info.runtime_state. + /// Holds a @c kfd_dbg_runtime_state value (see @c kfd_ioctl.h). + uint32_t runtime_state = 0; + + /// @brief Mirrors @c kfd_process::exception_enable_mask. + /// Bitmask of exception classes that are forwarded to the debugger. + uint64_t exception_enable_mask = 0; + + /// @brief Mirrors @c kfd_process::dbg_ev_file (flattened from @c struct @c file* to fd). + /// File descriptor used as the debugger notification / poll target. + /// -1 when no debugger is attached. + int dbg_fd = -1; + + /// @brief Owns @ref dbg_fd when the daemon received it out-of-band. + /// @details In daemon mode the notifier is a descriptor dup'd into the + /// daemon's own fd table (SCM_RIGHTS), which the session must close when the + /// debug session ends (DISABLE) or the process tears down. Engaged only in + /// daemon mode; empty in local mode, where @ref dbg_fd is the debugger's own + /// descriptor and is not owned here. RAII replaces an explicit close. + UniqueFd owned_dbg_fd; + + /// @brief Mirrors @c kfd_process::debugger_process (stored as pid instead of pointer). + /// Linux PID of the attached debugger (ptrace parent). 0 when not attached. + pid_t debugger_pid = 0; + }; + /// @brief Per-page translation entry, mirroring HW PTE fields. /// @details Stores the host pointer for VA→PA translation and the PTE MTYPE /// that the GPU MMU uses to override instruction-level caching. On real @@ -191,6 +277,9 @@ class KfdProcess { std::mutex runtime_mutex_; RuntimeState runtime_state_; + mutable std::mutex debug_mutex_; + DebugSession debug_session_; + private: }; diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/linux_kfd.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/linux_kfd.cpp index ac1952e10cf3..411148f60afc 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/linux_kfd.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/linux_kfd.cpp @@ -137,6 +137,8 @@ const char *LinuxKfd::ioctl_name(unsigned long request) { return "SET_SCRATCH_VA"; case AMDKFD_IOC_SET_TRAP_HANDLER: return "SET_TRAP_HANDLER"; + case AMDKFD_IOC_DBG_TRAP: + return "DBG_TRAP"; case AMDKFD_IOC_SET_XNACK_MODE: return "SET_XNACK"; case AMDKFD_IOC_SET_MEMORY_POLICY: diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/remote_driver.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/remote_driver.cpp index 240407a0695a..57e7c9e09e0a 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/remote_driver.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/remote_driver.cpp @@ -39,6 +39,7 @@ constexpr bool has_embedded_pointers(unsigned long request) { case AMDKFD_IOC_MAP_MEMORY_TO_GPU: case AMDKFD_IOC_UNMAP_MEMORY_FROM_GPU: case AMDKFD_IOC_GET_PROCESS_APERTURES_NEW: + case AMDKFD_IOC_DBG_TRAP: return true; case AMDKFD_IOC_SVM: // SVM's variable-length attribute array is part of the ioctl payload, not a @@ -373,6 +374,10 @@ int RemoteDriver::send_ioctl(unsigned long request, void *arg) { uint64_t saved_events_ptr = 0; uint64_t saved_apertures_ptr = 0; uint64_t saved_device_ids_ptr = 0; + uint64_t saved_dbg_rinfo_ptr = 0; + uint32_t saved_dbg_rinfo_size = 0; + uint64_t saved_dbg_snapshot_ptr = 0; + size_t saved_dbg_snapshot_cap = 0; if (has_embedded_pointers(request)) { switch (request) { case AMDKFD_IOC_WAIT_EVENTS: @@ -387,6 +392,23 @@ int RemoteDriver::send_ioctl(unsigned long request, void *arg) { saved_device_ids_ptr = static_cast(arg)->device_ids_array_ptr; break; + case AMDKFD_IOC_DBG_TRAP: { + auto *dbg = static_cast(arg); + switch (dbg->op) { + case KFD_IOC_DBG_TRAP_ENABLE: + saved_dbg_rinfo_ptr = dbg->enable.rinfo_ptr; + saved_dbg_rinfo_size = dbg->enable.rinfo_size; + break; + case KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT: + saved_dbg_snapshot_ptr = dbg->device_snapshot.snapshot_buf_ptr; + saved_dbg_snapshot_cap = + static_cast(dbg->device_snapshot.num_devices) * dbg->device_snapshot.entry_size; + break; + default: + break; + } + break; + } default: break; } @@ -424,6 +446,25 @@ int RemoteDriver::send_ioctl(unsigned long request, void *arg) { buf.resize(buf.size() + aperture_args->num_of_nodes * sizeof(kfd_process_device_apertures)); break; } + case AMDKFD_IOC_DBG_TRAP: { + auto *dbg = reinterpret_cast(args_base); + size_t inline_size = 0; + switch (dbg->op) { + case KFD_IOC_DBG_TRAP_ENABLE: + inline_size = + std::min(static_cast(dbg->enable.rinfo_size), sizeof(kfd_runtime_info)); + break; + case KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT: + inline_size = + static_cast(dbg->device_snapshot.num_devices) * dbg->device_snapshot.entry_size; + break; + default: + break; + } + if (inline_size > 0) + buf.resize(buf.size() + inline_size); + break; + } default: break; } @@ -439,8 +480,29 @@ int RemoteDriver::send_ioctl(unsigned long request, void *arg) { ireq->ioctl_cmd = static_cast(request); ireq->args_bytes = static_cast(buf.size() - prefix); - if (!rpc_send_exact(sock_, buf.data(), buf.size())) - return -1; + // For DBG_TRAP ENABLE, hand the debugger's notifier pipe write-end to the + // daemon as an SCM_RIGHTS fd. The daemon substitutes it into the ioctl's + // dbg_fd so the driver can wake the debugger when a wave stops — the same fd + // the real kernel would receive through the ioctl. KFD_INVALID_FD (0xffffffff) + // casts to -1 and is not sent. + int send_fd = -1; + if (request == AMDKFD_IOC_DBG_TRAP) { + auto *dbg = static_cast(arg); + if (dbg->op == KFD_IOC_DBG_TRAP_ENABLE && static_cast(dbg->enable.dbg_fd) >= 0) + send_fd = static_cast(dbg->enable.dbg_fd); + } + if (send_fd >= 0) { + if (rpc_send_msg(sock_, buf.data(), buf.size(), &send_fd, 1) <= 0) { + // Preserve the transport errno — e.g. EBADF when the client handed us a + // closed notifier fd for SCM_RIGHTS — instead of a bare -1, which the + // interposer would surface as EPERM (-EPERM == -1). + int err = errno; + return err > 0 ? -err : -1; + } + } else if (!rpc_send_exact(sock_, buf.data(), buf.size())) { + int err = errno; + return err > 0 ? -err : -1; + } // Receive response — may include a memfd via SCM_RIGHTS for ALLOC_MEMORY. uint8_t resp_header_buf[sizeof(RpcHeader)]; @@ -477,6 +539,20 @@ int RemoteDriver::send_ioctl(unsigned long request, void *arg) { static_cast(arg)->device_ids_array_ptr = saved_device_ids_ptr; break; + case AMDKFD_IOC_DBG_TRAP: { + auto *dbg = static_cast(arg); + switch (dbg->op) { + case KFD_IOC_DBG_TRAP_ENABLE: + dbg->enable.rinfo_ptr = saved_dbg_rinfo_ptr; + break; + case KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT: + dbg->device_snapshot.snapshot_buf_ptr = saved_dbg_snapshot_ptr; + break; + default: + break; + } + break; + } default: break; } @@ -499,6 +575,37 @@ int RemoteDriver::send_ioctl(unsigned long request, void *arg) { std::min(aperture_args->num_of_nodes * sizeof(kfd_process_device_apertures), extra)); break; } + case AMDKFD_IOC_DBG_TRAP: { + auto *dbg = static_cast(arg); + void *dst = nullptr; + size_t copy_len = 0; + switch (dbg->op) { + case KFD_IOC_DBG_TRAP_ENABLE: + // Only propagate runtime-info bytes on success; a failed op (e.g. + // -EBADF from a rejected notifier fd) must not mutate caller memory + // or dereference the saved output pointer, matching local mode. + if (resp->result == 0) { + dst = reinterpret_cast(saved_dbg_rinfo_ptr); + copy_len = std::min(static_cast(saved_dbg_rinfo_size), + std::min(static_cast(dbg->enable.rinfo_size), extra)); + } + break; + case KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT: + // Only propagate snapshot bytes on success; a failed op (e.g. -ENOSYS) + // must not mutate caller memory or dereference the saved output + // pointer. Clamp any successful copy to the original buffer capacity. + if (resp->result == 0) { + dst = reinterpret_cast(saved_dbg_snapshot_ptr); + copy_len = std::min(saved_dbg_snapshot_cap, extra); + } + break; + default: + break; + } + if (dst != nullptr && copy_len > 0) + std::memcpy(dst, payload.data() + arg_size, copy_len); + break; + } default: break; } diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.cpp index fe71f728a3be..812f7968d2b7 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.cpp @@ -229,6 +229,12 @@ int SimulatedKfd::open() { } uint32_t pid = next_process_id_++; auto proc = std::make_shared(pid, static_cast(gpus_.size())); + // client_pid_ caches getpid() at open() time; DBG_TRAP uses it to resolve a + // self-debug target, so it must match the caller's live pid. A fork() child + // inherits this cache stale, but the interposer's reset_after_fork() drops the + // driver so the child re-open()s (and re-caches here) before any ioctl — + // DBG_TRAP self-resolution therefore requires a post-fork re-open. + proc->set_client_pid(static_cast(getpid())); proc->event_state_.reset(); for (auto &g : gpus_) { if (auto *mem = g.soc ? g.soc->memory() : nullptr) { @@ -548,7 +554,7 @@ int SimulatedKfd::ioctl(uint32_t process_id, unsigned long request, void *arg) { } int SimulatedKfd::dispatch_ioctl(KfdProcess &proc, unsigned long request, void *arg) { - util::Logger::cp("IOCTL pid=", proc.process_id(), " ", LinuxKfd::ioctl_name(request)); + util::Logger::driver("IOCTL pid=", proc.process_id(), " ", LinuxKfd::ioctl_name(request)); switch (canonical_ioctl_request(request)) { case AMDKFD_IOC_GET_VERSION: @@ -591,6 +597,8 @@ int SimulatedKfd::dispatch_ioctl(KfdProcess &proc, unsigned long request, void * return get_available_memory_ioctl(proc, arg); case AMDKFD_IOC_RUNTIME_ENABLE: return runtime_enable_ioctl(proc, arg); + case AMDKFD_IOC_DBG_TRAP: + return debug_trap_ioctl(proc, arg); case AMDKFD_IOC_SET_SCRATCH_BACKING_VA: { auto *a = static_cast(arg); uint32_t ord = gpu_ordinal(a->gpu_id); @@ -1741,6 +1749,170 @@ int SimulatedKfd::runtime_enable_ioctl(KfdProcess &proc, void *arg) { return 0; } +std::shared_ptr SimulatedKfd::find_process_by_client_pid(pid_t pid) const { + if (pid == 0) + return nullptr; + std::lock_guard lk(process_mutex_); + for (auto &[id, proc] : processes_) + if (proc->client_pid() == pid) + return proc; + return nullptr; +} + +// in real kernel, amd/amdkfd/kfd_chardev.c kfd_ioctl_set_debug_trap +int SimulatedKfd::debug_trap_ioctl(KfdProcess &caller, void *arg) { + auto *args = static_cast(arg); + util::Logger::driver("DBG_TRAP pid=", args->pid, " op=", args->op); + + KfdProcess *target = nullptr; + std::shared_ptr target_ref; + if (caller.client_pid() != 0 && static_cast(args->pid) == caller.client_pid()) { + target = &caller; // self-debug (local mode) + } else { + target_ref = find_process_by_client_pid(static_cast(args->pid)); + target = target_ref.get(); + } + if (target == nullptr) + return -ESRCH; + + const bool self_debug = (target == &caller); + + std::lock_guard lk(target->debug_mutex_); + auto &sess = target->debug_session_; + + // Cross-process authorization is by-pid only: a non-self caller is admitted + // solely when it is the debugger already attached to this session + // (sess.debugger_pid, set by a prior successful ENABLE). This gate therefore + // only re-admits re-entrant ops from an already-attached debugger. Because a + // fresh session has debugger_pid == 0, it structurally rejects the *first* + // cross-process ENABLE with EPERM: cross-process attach is intentionally + // closed until it is implemented (#8364). Self-debug (local mode, the only + // supported path today) and DISABLE are exempt. The real kernel instead gates + // the first attach on a live ptrace relationship, which is deferred with the + // rest of cross-process support. + if (!self_debug && args->op != KFD_IOC_DBG_TRAP_DISABLE && + sess.debugger_pid != caller.client_pid()) + return -EPERM; + + // Non-ENABLE ops require an active debug session (kernel: EINVAL). + if (args->op != KFD_IOC_DBG_TRAP_ENABLE && !sess.enabled) + return -EINVAL; + + // Live runtime-enable state, set by ROCr's AMDKFD_IOC_RUNTIME_ENABLE. + const bool runtime_enabled = [&] { + std::lock_guard rlk(target->runtime_mutex_); + return target->runtime_state_.enabled; + }(); + + // https://github.com/torvalds/linux/blob/a635d6748234582ea287c5ffeae28b9b23f91c7e/drivers/gpu/drm/amd/amdkfd/kfd_chardev.c#L3132-L3142 + switch (args->op) { + case KFD_IOC_DBG_TRAP_SET_WAVE_LAUNCH_OVERRIDE: + case KFD_IOC_DBG_TRAP_SET_WAVE_LAUNCH_MODE: + case KFD_IOC_DBG_TRAP_SUSPEND_QUEUES: + case KFD_IOC_DBG_TRAP_RESUME_QUEUES: + case KFD_IOC_DBG_TRAP_SET_NODE_ADDRESS_WATCH: + case KFD_IOC_DBG_TRAP_CLEAR_NODE_ADDRESS_WATCH: + case KFD_IOC_DBG_TRAP_SET_FLAGS: + if (!runtime_enabled) + return -EPERM; + break; + default: + break; + } + + // https://github.com/torvalds/linux/blob/a635d6748234582ea287c5ffeae28b9b23f91c7e/drivers/gpu/drm/amd/amdkfd/kfd_chardev.c#L3144 + if (args->op == KFD_IOC_DBG_TRAP_SET_NODE_ADDRESS_WATCH || + args->op == KFD_IOC_DBG_TRAP_CLEAR_NODE_ADDRESS_WATCH) { + const uint32_t gpu_id = args->op == KFD_IOC_DBG_TRAP_SET_NODE_ADDRESS_WATCH + ? args->set_node_address_watch.gpu_id + : args->clear_node_address_watch.gpu_id; + if (find_gpu(gpu_id) == nullptr) + return -ENODEV; + } + + // https://github.com/torvalds/linux/blob/a635d6748234582ea287c5ffeae28b9b23f91c7e/drivers/gpu/drm/amd/amdkfd/kfd_chardev.c#L3158 + switch (args->op) { + // https://github.com/torvalds/linux/blob/a635d6748234582ea287c5ffeae28b9b23f91c7e/drivers/gpu/drm/amd/amdkfd/kfd_debug.c#L788-L847 + case KFD_IOC_DBG_TRAP_ENABLE: { + if (sess.enabled) + return -EALREADY; // target process is already debug enabled + + const int dbg_fd = static_cast(args->enable.dbg_fd); + // Validate the notifier before trusting it. In daemon mode the fd was + // received via SCM_RIGHTS and already substituted into our fd space; in + // local mode it is the debugger's own descriptor. Either way the driver + // *writes* to it to wake the debugger, so it must be a live, writable + // descriptor. fcntl(F_GETFL) both proves the fd is open (EBADF otherwise) + // and reports its access mode, so a read-only or otherwise unusable fd — + // e.g. one a client passed over SCM_RIGHTS that is not a real event target — + // is rejected instead of being stored on the session. + const int fl = fcntl(dbg_fd, F_GETFL); + if (fl == -1 || (fl & O_ACCMODE) == O_RDONLY) + return -EBADF; + + sess.enabled = true; + sess.debugger_pid = caller.client_pid(); + sess.dbg_fd = dbg_fd; + // In daemon mode the session owns the transferred fd and releases it via + // RAII (on DISABLE or process teardown). In local mode dbg_fd is the + // debugger's own descriptor, left for the debugger to close. + if (daemon_mode_) + sess.owned_dbg_fd = UniqueFd(dbg_fd); + sess.exception_enable_mask = args->enable.exception_mask; + + // Snapshot the runtime-enable state under a single lock so the marshaled + // runtime_state, r_debug and ttmp_setup stay mutually consistent: a + // concurrent RUNTIME_ENABLE/DISABLE must not change them between reads. + // Lock order debug_mutex_ -> runtime_mutex_ is already held that way. + // Kernel: kfd_dbg_trap_enable copies the saved runtime info and returns its + // size. + kfd_runtime_info info{}; + { + std::lock_guard rlk(target->runtime_mutex_); + const auto &rt = target->runtime_state_; + sess.runtime_state = rt.enabled ? DEBUG_RUNTIME_STATE_ENABLED : DEBUG_RUNTIME_STATE_DISABLED; + info.r_debug = rt.r_debug; + info.ttmp_setup = (rt.mode_mask & KFD_RUNTIME_ENABLE_MODE_TTMP_SAVE_MASK) ? 1u : 0u; + } + info.runtime_state = sess.runtime_state; + size_t copy_size = std::min(static_cast(args->enable.rinfo_size), sizeof(info)); + if (args->enable.rinfo_ptr != 0 && copy_size > 0) + std::memcpy(reinterpret_cast(static_cast(args->enable.rinfo_ptr)), &info, + copy_size); + args->enable.rinfo_size = sizeof(info); + return 0; + } + case KFD_IOC_DBG_TRAP_DISABLE: + // Resetting the session releases the debugger notifier: in daemon mode the + // session's UniqueFd closes the SCM_RIGHTS-transferred fd it owns; in local + // mode nothing is owned, so the debugger's own fd is left untouched. + sess = KfdProcess::DebugSession{}; + return 0; + + // Recognized ops whose handlers are not wired up yet. The kernel dispatches + // each to a real implementation; the skeleton reports ENOSYS ("not + // implemented") so a debugger can tell a stubbed-but-valid op apart from a + // genuinely unknown one (EINVAL below). Each case graduates out of this group + // as its handler lands. + case KFD_IOC_DBG_TRAP_SEND_RUNTIME_EVENT: + case KFD_IOC_DBG_TRAP_SET_EXCEPTIONS_ENABLED: + case KFD_IOC_DBG_TRAP_SET_WAVE_LAUNCH_OVERRIDE: + case KFD_IOC_DBG_TRAP_SET_WAVE_LAUNCH_MODE: + case KFD_IOC_DBG_TRAP_SUSPEND_QUEUES: + case KFD_IOC_DBG_TRAP_RESUME_QUEUES: + case KFD_IOC_DBG_TRAP_SET_NODE_ADDRESS_WATCH: + case KFD_IOC_DBG_TRAP_CLEAR_NODE_ADDRESS_WATCH: + case KFD_IOC_DBG_TRAP_SET_FLAGS: + case KFD_IOC_DBG_TRAP_QUERY_DEBUG_EVENT: + case KFD_IOC_DBG_TRAP_QUERY_EXCEPTION_INFO: + case KFD_IOC_DBG_TRAP_GET_QUEUE_SNAPSHOT: + case KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT: + return -ENOSYS; + default: + return -EINVAL; + } +} + int SimulatedKfd::set_xnack_mode_ioctl(void *arg) { auto *args = static_cast(arg); args->xnack_enabled = 0; diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.h index b6e441cb9d3c..b97d967ed7c0 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/simulated_kfd.h @@ -167,6 +167,13 @@ class SimulatedKfd : public LinuxKfd { /// @brief Look up the local-mode process. std::shared_ptr find_local_process() const; + /// @brief Look up a KfdProcess by its client (Linux) pid. Used by + /// AMDKFD_IOC_DBG_TRAP to resolve the debug target, mirroring the kernel's + /// kfd_lookup_process_by_pid(). + /// @param pid Client (Linux) pid of the target process. + /// @return The matching KfdProcess, or nullptr if none matches. + std::shared_ptr find_process_by_client_pid(pid_t pid) const; + /// @brief Look up a GpuDevice by gpu_id. Returns nullptr if not found. GpuDevice *find_gpu(uint32_t gpu_id); const GpuDevice *find_gpu(uint32_t gpu_id) const; @@ -218,6 +225,7 @@ class SimulatedKfd : public LinuxKfd { int ipc_import_handle_ioctl(KfdProcess &proc, void *arg); int svm_ioctl(KfdProcess &proc, void *arg); int runtime_enable_ioctl(KfdProcess &proc, void *arg); + int debug_trap_ioctl(KfdProcess &caller, void *arg); int set_xnack_mode_ioctl(void *arg); int get_tile_config_ioctl(void *arg); bool allocate_scratch_backing(uint32_t process_id, uint64_t gpu_va, size_t size); diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/vm/rj_vm.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/vm/rj_vm.cpp index 79323e1f78a4..1a2486af032b 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/vm/rj_vm.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/vm/rj_vm.cpp @@ -118,6 +118,20 @@ void reconstruct_embedded_pointers(uint32_t cmd, void *arg, size_t arg_size, siz args->kfd_process_device_apertures_ptr = reinterpret_cast(extra); break; } + case AMDKFD_IOC_DBG_TRAP: { + auto *args = static_cast(arg); + switch (args->op) { + case KFD_IOC_DBG_TRAP_ENABLE: + args->enable.rinfo_ptr = reinterpret_cast(extra); + break; + case KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT: + args->device_snapshot.snapshot_buf_ptr = reinterpret_cast(extra); + break; + default: + break; + } + break; + } default: break; } @@ -229,8 +243,39 @@ rj_status_t execute_impl(SimulatedKfd *driver, uint32_t process_id, rj_vm_cmd_t auto arg_size = _IOC_SIZE(cmd->cmd); reconstruct_embedded_pointers(cmd->cmd, cmd->buf, arg_size, cmd->buf_size); + // For DBG_TRAP ENABLE the debugger's notifier pipe arrives as an SCM_RIGHTS + // fd in cmd->in_handle (already in the daemon's fd space). Substitute it for + // the client-side fd number in the payload so the driver signals the right + // pipe when a wave stops (the kernel receives the same fd via the ioctl). On + // success the debug session takes ownership (cmd->in_handle cleared so the + // transport does not close it); otherwise the transport reclaims it. Only + // daemon mode transfers the fd; local mode passes the debugger's own fd + // through the interposer and leaves cmd->in_handle at -1. + // + // The client-supplied dbg_fd is a number in the *client's* fd table and is + // never trusted in the daemon's namespace. Overwrite it unconditionally for + // ENABLE: with the transferred fd when one arrived via SCM_RIGHTS, otherwise + // with KFD_INVALID_FD so the handler's fcntl() check rejects it. Leaving the + // client's integer in place would let a client that omits the ancillary fd + // point the session at an arbitrary daemon-owned descriptor (a confused-deputy + // fd substitution). + bool adopting_notifier = false; + if (driver->daemon_mode() && cmd->cmd == AMDKFD_IOC_DBG_TRAP) { + auto *dbg = static_cast(cmd->buf); + if (dbg->op == KFD_IOC_DBG_TRAP_ENABLE) { + if (cmd->in_handle >= 0) { + dbg->enable.dbg_fd = static_cast(cmd->in_handle); + adopting_notifier = true; + } else { + dbg->enable.dbg_fd = KFD_INVALID_FD; + } + } + } + cmd->result = driver->ioctl(process_id, cmd->cmd, cmd->buf); cmd->shared_handle = -1; + if (adopting_notifier && cmd->result == 0) + cmd->in_handle = -1; if (cmd->cmd == AMDKFD_IOC_ALLOC_MEMORY_OF_GPU && cmd->result == 0) { auto *alloc_args = static_cast(cmd->buf); diff --git a/emulation/rocjitsu/lib/util/include/util/log.h b/emulation/rocjitsu/lib/util/include/util/log.h index 081b48ed253c..14a3f3f0e37f 100644 --- a/emulation/rocjitsu/lib/util/include/util/log.h +++ b/emulation/rocjitsu/lib/util/include/util/log.h @@ -41,6 +41,7 @@ class Logger { GROUP_VM = 0, ///< Instruction execution, VGPR dumps, memory access. GROUP_CP = 1, ///< Command processor: doorbell, dispatch, completion. GROUP_DBT_HOOKS = 2, ///< ROCR HSA tools DBT hook tracing. + GROUP_DRIVER = 3, ///< KFD driver: ioctl dispatch, process/session state. }; /// @brief Human-readable group name for log prefixes. @@ -52,6 +53,8 @@ class Logger { return "CP"; case GROUP_DBT_HOOKS: return "DBT_HOOKS"; + case GROUP_DRIVER: + return "DRIVER"; default: return " "; } @@ -178,6 +181,18 @@ class Logger { print(std::forward(fn)); } + /// @brief Convenience for logging in GROUP_DRIVER, variadic. + template static void driver(Args &&...args) { + print(std::forward(args)...); + } + + /// @brief Convenience for logging in GROUP_DRIVER, lambda. + template + requires std::invocable + static void driver(Fn &&fn) { + print(std::forward(fn)); + } + private: static std::mutex &mutex() { static std::mutex m; diff --git a/emulation/rocjitsu/tests/kfd_ioctl_test.cpp b/emulation/rocjitsu/tests/kfd_ioctl_test.cpp index 15a1ee4e82bc..d87304b4ca0a 100644 --- a/emulation/rocjitsu/tests/kfd_ioctl_test.cpp +++ b/emulation/rocjitsu/tests/kfd_ioctl_test.cpp @@ -3,7 +3,10 @@ #include "rocjitsu/config/config_loader.h" #include "rocjitsu/kmd/linux/kfd_ioctl_utils.h" +#include "rocjitsu/kmd/linux/remote_driver.h" +#include "rocjitsu/kmd/linux/rpc.h" #include "rocjitsu/kmd/linux/simulated_kfd.h" +#include "rocjitsu/vm/rj_vm.h" #include "rocjitsu/vm/virtual_machine.h" #include "embedded_schema.h" @@ -14,13 +17,18 @@ #include #include +#include #include +#include #include #include #include +#include #include +#include #include +#include #include namespace { @@ -95,12 +103,27 @@ class KfdIoctlTest : public ::testing::Test { void TearDown() override { if (driver_) driver_->close(); + for (int fd : debug_fds_) + ::close(fd); + debug_fds_.clear(); + } + + // Returns a real eventfd standing in for a debugger's notification target. + // kfd_dbg_trap_enable() takes a reference to dbg_fd via fget(), so the driver + // rejects an unusable descriptor; enable-success tests therefore need a live + // fd. Tracked here so TearDown closes it. + int make_debug_fd() { + int fd = eventfd(0, EFD_CLOEXEC); + EXPECT_GE(fd, 0); + debug_fds_.push_back(fd); + return fd; } rocjitsu::config::LoadedConfig loaded_; std::unique_ptr engine_; rocjitsu::SoC *soc_ = nullptr; rocjitsu::SimulatedKfd *driver_ = nullptr; + std::vector debug_fds_; }; TEST_F(KfdIoctlTest, SetMemoryPolicy) { @@ -339,4 +362,859 @@ TEST_F(KfdIoctlTest, OpenRefcountSurvivesDupThenPrimaryClose) { ASSERT_GE(driver_->open(), 0); } +// --- AMDKFD_IOC_DBG_TRAP dispatch skeleton (self-debug in local mode) --- + +TEST_F(KfdIoctlTest, DbgTrapUnknownPidReturnsESRCH) { + kfd_ioctl_dbg_trap_args args{}; + args.pid = 0x7fffffff; // a pid that maps to no emulated process + args.op = KFD_IOC_DBG_TRAP_ENABLE; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), -ESRCH); +} + +TEST_F(KfdIoctlTest, DbgTrapOpBeforeEnableReturnsEINVAL) { + kfd_ioctl_dbg_trap_args args{}; + args.pid = static_cast(getpid()); + args.op = KFD_IOC_DBG_TRAP_SET_EXCEPTIONS_ENABLED; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), -EINVAL); +} + +TEST_F(KfdIoctlTest, DbgTrapBareDisableReturnsEINVAL) { + // DISABLE with no active session has nothing to tear down. The by-pid gate's + // DISABLE exemption only skips the cross-process authorization check, not the + // session-enabled requirement, so a bare DISABLE is still rejected with + // EINVAL like any other non-ENABLE op on a disabled session. + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = static_cast(getpid()); + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &dis), -EINVAL); +} + +TEST_F(KfdIoctlTest, DbgTrapEnablePopulatesRuntimeInfoThenDisable) { + // ROCr's runtime-enable must have run for the session to report ENABLED. + kfd_ioctl_runtime_enable_args rt{}; + rt.mode_mask = KFD_RUNTIME_ENABLE_MODE_ENABLE_MASK | KFD_RUNTIME_ENABLE_MODE_TTMP_SAVE_MASK; + rt.r_debug = 0xcafef00d; + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_RUNTIME_ENABLE, &rt), 0); + + kfd_runtime_info info{}; + info.runtime_state = 0xdeadbeef; // sentinel the driver must overwrite + kfd_ioctl_dbg_trap_args args{}; + args.pid = static_cast(getpid()); + args.op = KFD_IOC_DBG_TRAP_ENABLE; + args.enable.dbg_fd = make_debug_fd(); + args.enable.rinfo_ptr = reinterpret_cast(&info); + args.enable.rinfo_size = sizeof(info); + + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), 0); + EXPECT_EQ(args.enable.rinfo_size, sizeof(kfd_runtime_info)); + EXPECT_EQ(info.runtime_state, static_cast(DEBUG_RUNTIME_STATE_ENABLED)); + EXPECT_EQ(info.r_debug, 0xcafef00dULL); + EXPECT_EQ(info.ttmp_setup, 1u); + + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = static_cast(getpid()); + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &dis), 0); +} + +TEST_F(KfdIoctlTest, DbgTrapDoubleEnableReturnsEALREADY) { + kfd_ioctl_dbg_trap_args args{}; + args.pid = static_cast(getpid()); + args.op = KFD_IOC_DBG_TRAP_ENABLE; + args.enable.dbg_fd = make_debug_fd(); + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), 0); + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), -EALREADY); +} + +// Hammers ENABLE/DISABLE on one session from many threads to exercise +// debug_mutex_ under ThreadSanitizer. Races are legitimate: a losing ENABLE +// sees EALREADY and a losing DISABLE sees EINVAL. The invariant is that the +// driver serializes them without a data race or torn session state — every call +// returns one of the well-defined codes, never a crash or a bogus errno. Uses +// self-debug (target pid == getpid()) so the whole cycle stays on debug_mutex_ +// and runtime_mutex_. In local mode the session never owns dbg_fd, so a single +// shared eventfd can back every ENABLE. +TEST_F(KfdIoctlTest, DbgTrapConcurrentEnableDisableIsRaceFree) { + const int fd = make_debug_fd(); + const auto pid = static_cast(getpid()); + constexpr int kThreads = 8; + constexpr int kIters = 250; + + std::vector threads; + threads.reserve(kThreads); + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&, t] { + for (int i = 0; i < kIters; ++i) { + if ((t + i) & 1) { + kfd_ioctl_dbg_trap_args en{}; + en.pid = pid; + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = fd; + const int rc = driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &en); + EXPECT_TRUE(rc == 0 || rc == -EALREADY) << "enable rc=" << rc; + } else { + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = pid; + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + const int rc = driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &dis); + EXPECT_TRUE(rc == 0 || rc == -EINVAL) << "disable rc=" << rc; + } + } + }); + } + for (auto &th : threads) + th.join(); +} + +TEST_F(KfdIoctlTest, DbgTrapEnableBadFdReturnsEBADF) { + // kfd_dbg_trap_enable() fails with -EBADF when it cannot fget(dbg_fd); an + // unusable notification target must not be stored on the session. + kfd_ioctl_dbg_trap_args args{}; + args.pid = static_cast(getpid()); + args.op = KFD_IOC_DBG_TRAP_ENABLE; + args.enable.dbg_fd = KFD_INVALID_FD; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), -EBADF); + + // The rejected enable left the session disabled, so a follow-up op is refused + // with -EINVAL rather than admitted against a half-initialized session. + kfd_ioctl_dbg_trap_args after{}; + after.pid = static_cast(getpid()); + after.op = KFD_IOC_DBG_TRAP_SET_EXCEPTIONS_ENABLED; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &after), -EINVAL); +} + +// The driver signals the notifier to wake the debugger, so a read-only +// descriptor is an unusable target even though it is a valid open fd. ENABLE +// validates the access mode (fcntl F_GETFL) and rejects a non-writable fd with +// -EBADF, matching a closed one; it must not be stored on the session. +TEST_F(KfdIoctlTest, DbgTrapEnableReadOnlyFdReturnsEBADF) { + const int ro_fd = ::open("/dev/null", O_RDONLY | O_CLOEXEC); + ASSERT_GE(ro_fd, 0); + debug_fds_.push_back(ro_fd); // closed in TearDown + + kfd_ioctl_dbg_trap_args args{}; + args.pid = static_cast(getpid()); + args.op = KFD_IOC_DBG_TRAP_ENABLE; + args.enable.dbg_fd = static_cast(ro_fd); + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &args), -EBADF); + + // The rejected enable left the session disabled. + kfd_ioctl_dbg_trap_args after{}; + after.pid = static_cast(getpid()); + after.op = KFD_IOC_DBG_TRAP_SET_EXCEPTIONS_ENABLED; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &after), -EINVAL); +} + +TEST_F(KfdIoctlTest, DbgTrapHwOpWithoutRuntimeReturnsEPERM) { + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(getpid()); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = make_debug_fd(); + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &en), 0); + + // SET_FLAGS is a DBG_HW_OP: it requires AMDKFD_IOC_RUNTIME_ENABLE first. + kfd_ioctl_dbg_trap_args flags{}; + flags.pid = static_cast(getpid()); + flags.op = KFD_IOC_DBG_TRAP_SET_FLAGS; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &flags), -EPERM); +} + +TEST_F(KfdIoctlTest, DbgTrapWatchBadGpuReturnsENODEV) { + kfd_ioctl_runtime_enable_args rt{}; + rt.mode_mask = KFD_RUNTIME_ENABLE_MODE_ENABLE_MASK; + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_RUNTIME_ENABLE, &rt), 0); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(getpid()); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = make_debug_fd(); + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &en), 0); + + // Runtime is enabled, so the HW-op gate passes and the gpu-id check runs. + kfd_ioctl_dbg_trap_args watch{}; + watch.pid = static_cast(getpid()); + watch.op = KFD_IOC_DBG_TRAP_SET_NODE_ADDRESS_WATCH; + watch.set_node_address_watch.gpu_id = 0xdeadbeef; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &watch), -ENODEV); +} + +TEST_F(KfdIoctlTest, DbgTrapAdmittedOpNotYetImplemented) { + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(getpid()); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = make_debug_fd(); + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &en), 0); + + // QUERY_DEBUG_EVENT is not a HW-op, so the gate ladder admits it; the handler + // itself is added by a later change and currently reports not-implemented. + kfd_ioctl_dbg_trap_args q{}; + q.pid = static_cast(getpid()); + q.op = KFD_IOC_DBG_TRAP_QUERY_DEBUG_EVENT; + EXPECT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &q), -ENOSYS); +} + +// Local mode borrows the debugger's own fd (the session does not own it), so +// DISABLE must leave it open for the debugger to close. Only daemon mode, which +// dup'd the fd via SCM_RIGHTS, releases it on teardown. +TEST_F(KfdIoctlTest, DbgTrapLocalDisableLeavesDebuggerFdOpen) { + const int fd = make_debug_fd(); + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(getpid()); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = fd; + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &en), 0); + + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = static_cast(getpid()); + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &dis), 0); + + EXPECT_NE(fcntl(fd, F_GETFD), -1) << "local-mode DISABLE must not close the debugger's fd"; +} + +// The kernel copies min(user_size, sizeof(runtime_info)) bytes back and reports +// the full struct size. An undersized buffer must truncate the copy — never +// writing past the caller's buffer — while still reporting sizeof(kfd_runtime_info). +TEST_F(KfdIoctlTest, DbgTrapEnableUndersizedRuntimeInfoTruncates) { + kfd_ioctl_runtime_enable_args rt{}; + rt.mode_mask = KFD_RUNTIME_ENABLE_MODE_ENABLE_MASK; + rt.r_debug = 0xcafef00d; + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_RUNTIME_ENABLE, &rt), 0); + + // Buffer smaller than kfd_runtime_info, backed by a full-size array so an + // overrunning copy is caught by the sentinel check below. + constexpr uint32_t kSmall = 8; + static_assert(kSmall < sizeof(kfd_runtime_info)); + constexpr uint8_t kSentinel = 0xCD; + std::array buf; + buf.fill(kSentinel); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(getpid()); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = make_debug_fd(); + en.enable.rinfo_ptr = reinterpret_cast(buf.data()); + en.enable.rinfo_size = kSmall; + ASSERT_EQ(driver_->ioctl(AMDKFD_IOC_DBG_TRAP, &en), 0); + + EXPECT_EQ(en.enable.rinfo_size, sizeof(kfd_runtime_info)); // full size reported + for (size_t i = kSmall; i < buf.size(); ++i) + EXPECT_EQ(buf[i], kSentinel) << "runtime-info copy overran the undersized buffer at byte " << i; +} + +// Exercises the RemoteDriver client stub against an in-process server that runs +// the real daemon-mode handler. A debugger may hand kfd_dbg_trap_enable a +// runtime-info buffer larger than kfd_runtime_info; the handler fills only +// sizeof(kfd_runtime_info) and reports that size, so bytes past it must survive +// the RPC round trip (local mode preserves them; the daemon path used to clobber +// them). Routing through RemoteDriver also locks in the DBG_TRAP embedded-pointer +// marshalling — a crash there would take the server thread, and thus this test +// process, down. +TEST_F(KfdIoctlTest, DbgTrapEnableOversizedRuntimeInfoPreservesTailInDaemonMode) { + ASSERT_NE(soc_, nullptr); + + rocjitsu::SimulatedKfd daemon_driver(*soc_, /*daemon_mode=*/true); + constexpr pid_t kClientPid = 4242; + uint32_t process_id = daemon_driver.open_process(kClientPid); + ASSERT_NE(process_id, 0u); + + int sv[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0) << strerror(errno); + + // Minimal stand-in for the daemon's RPC_IOCTL loop: reconstruct the inlined + // runtime-info pointer exactly as tools/rocjitsu does, run the real handler, + // then echo the args (plus any inline tail) back to the client. jthread (not + // thread) so an ASSERT_* failure below unwinds without calling + // std::terminate() on a still-joinable thread. + std::jthread server([&, server_fd = sv[1]] { + for (;;) { + rocjitsu::RpcHeader hdr{}; + if (!rocjitsu::rpc_recv_exact(server_fd, &hdr, sizeof(hdr))) + break; + if (hdr.opcode != rocjitsu::RPC_IOCTL) { + rocjitsu::RpcHeader resp{}; + resp.request_id = hdr.request_id; + rocjitsu::rpc_send_exact(server_fd, &resp, sizeof(resp)); + if (hdr.opcode == rocjitsu::RPC_CLOSE) + break; + continue; + } + std::vector payload(hdr.payload_bytes); + if (!rocjitsu::rpc_recv_exact(server_fd, payload.data(), hdr.payload_bytes)) + break; + auto *ireq = reinterpret_cast(payload.data()); + const uint32_t cmd = ireq->ioctl_cmd; + const size_t buf_size = ireq->args_bytes; + uint8_t *buf = payload.data() + sizeof(rocjitsu::RpcIoctlRequest); + + const size_t arg_size = rocjitsu::ioctl_arg_size(cmd); + if (cmd == AMDKFD_IOC_DBG_TRAP && buf_size > arg_size) { + auto *dbg = reinterpret_cast(buf); + if (dbg->op == KFD_IOC_DBG_TRAP_ENABLE) + dbg->enable.rinfo_ptr = reinterpret_cast(buf + arg_size); + } + + const int result = daemon_driver.ioctl(process_id, cmd, buf); + + rocjitsu::RpcHeader resp{}; + resp.opcode = rocjitsu::RPC_IOCTL; + resp.request_id = hdr.request_id; + resp.result = result; + resp.payload_bytes = static_cast(buf_size); + if (!rocjitsu::rpc_send_exact(server_fd, &resp, sizeof(resp))) + break; + if (buf_size > 0 && !rocjitsu::rpc_send_exact(server_fd, buf, buf_size)) + break; + } + ::close(server_fd); + }); + + rocjitsu::RemoteDriver rd(sv[0]); + + // Runtime-enable so the session reports ENABLED and carries r_debug/ttmp. + kfd_ioctl_runtime_enable_args rt{}; + rt.mode_mask = KFD_RUNTIME_ENABLE_MODE_ENABLE_MASK | KFD_RUNTIME_ENABLE_MODE_TTMP_SAVE_MASK; + rt.r_debug = 0xcafef00d; + ASSERT_EQ(rd.ioctl(AMDKFD_IOC_RUNTIME_ENABLE, &rt), 0); + + // Oversized runtime-info buffer: the 16-byte struct plus a 32-byte tail, + // pre-filled with a sentinel the handler must leave untouched. + constexpr size_t kCapacity = sizeof(kfd_runtime_info) + 32; + constexpr uint8_t kSentinel = 0xAB; + std::array rinfo_buf; + rinfo_buf.fill(kSentinel); + + // A live fd for the now-active daemon-mode validation; the daemon adopts it on + // ENABLE and releases it on DISABLE (RAII), so it is not tracked/closed here. + int notifier = eventfd(0, EFD_CLOEXEC); + ASSERT_GE(notifier, 0); + + kfd_ioctl_dbg_trap_args args{}; + args.pid = static_cast(kClientPid); + args.op = KFD_IOC_DBG_TRAP_ENABLE; + args.enable.dbg_fd = notifier; + args.enable.rinfo_ptr = reinterpret_cast(rinfo_buf.data()); + args.enable.rinfo_size = static_cast(kCapacity); + + ASSERT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &args), 0); + + // Returned info: the handler reports the true struct size, not the capacity, + // and fills the runtime state the debugger expects. + EXPECT_EQ(args.enable.rinfo_size, sizeof(kfd_runtime_info)); + kfd_runtime_info info{}; + std::memcpy(&info, rinfo_buf.data(), sizeof(info)); + EXPECT_EQ(info.runtime_state, static_cast(DEBUG_RUNTIME_STATE_ENABLED)); + EXPECT_EQ(info.r_debug, 0xcafef00dULL); + EXPECT_EQ(info.ttmp_setup, 1u); + + // Tail: every byte past the struct must retain the sentinel. + for (size_t i = sizeof(kfd_runtime_info); i < kCapacity; ++i) + EXPECT_EQ(rinfo_buf[i], kSentinel) << "runtime-info tail clobbered at byte " << i; + + // Daemon liveness: a follow-up ioctl still round-trips, proving the server + // survived the embedded-pointer marshalling and is still serving requests. + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = static_cast(kClientPid); + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + EXPECT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &dis), 0); + + rd.close(); // sends RPC_CLOSE so the server loop exits + server.join(); + EXPECT_EQ(daemon_driver.close(process_id), 0); +} + +// --- Daemon-mode DBG_TRAP notifier-fd transfer via SCM_RIGHTS --- +// +// In daemon mode the debugger's dbg_fd is a number in the *client's* fd table +// and is meaningless to the daemon. The client hands the real fd over +// out-of-band as SCM_RIGHTS ancillary data; the daemon receives it in its own +// fd space and the rj_vm_execute_as() glue substitutes it into DBG_TRAP +// ENABLE's dbg_fd so the debug session can later signal it, releasing it on +// DISABLE. These tests exercise the real rj_vm_execute_as() dispatch path +// (where the substitution and adoption live), not the raw driver ioctl. +class DbgTrapDaemonTest : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_EQ(rj_vm_create(CONFIG_PATH.c_str(), RJ_VM_MODE_DAEMON, &vm_), ROCJITSU_STATUS_SUCCESS); + ASSERT_NE(vm_, nullptr); + ASSERT_EQ(rj_vm_device_open(vm_, kClientPid, &process_id_), ROCJITSU_STATUS_SUCCESS); + ASSERT_NE(process_id_, 0u); + } + + void TearDown() override { + if (vm_ != nullptr) { + if (process_id_ != 0) + rj_vm_device_close(vm_, process_id_); + rj_vm_destroy(vm_); + } + } + + // Runs one ioctl through rj_vm_execute_as() (the daemon dispatch path), with + // an optional in_handle carried in cmd.in_handle. On return, *in_handle_out + // (when given) carries cmd.in_handle, which the glue clears to -1 once the + // debug session has adopted the transferred fd. + int execute(uint32_t cmd_id, void *buf, size_t buf_size, int in_handle, int *in_handle_out) { + rj_vm_cmd_t cmd{}; + cmd.cmd = cmd_id; + cmd.buf = buf; + cmd.buf_size = buf_size; + cmd.shared_handle = -1; + cmd.in_handle = in_handle; + rj_vm_execute_as(vm_, process_id_, &cmd); + if (in_handle_out != nullptr) + *in_handle_out = cmd.in_handle; + return cmd.result; + } + + int enable_with_notifier(int in_handle, int *in_handle_out) { + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(kClientPid); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = 0x0BADF00D; // meaningless client-side number; must be replaced + return execute(AMDKFD_IOC_DBG_TRAP, &en, sizeof(en), in_handle, in_handle_out); + } + + int disable() { + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = static_cast(kClientPid); + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + return execute(AMDKFD_IOC_DBG_TRAP, &dis, sizeof(dis), -1, nullptr); + } + + static constexpr rj_client_pid_t kClientPid = 4242; + rj_vm_t *vm_ = nullptr; + uint32_t process_id_ = 0; +}; + +// The transferred fd (in_handle) replaces the client-side dbg_fd in the payload +// and the session takes ownership (in_handle cleared so the transport does not +// reclaim it). +TEST_F(DbgTrapDaemonTest, EnableAdoptsTransferredNotifierFd) { + int notifier = eventfd(0, EFD_CLOEXEC); + ASSERT_GE(notifier, 0); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(kClientPid); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = 0x0BADF00D; // client-side number the daemon must replace + + int in_handle_out = -2; + ASSERT_EQ(execute(AMDKFD_IOC_DBG_TRAP, &en, sizeof(en), notifier, &in_handle_out), 0); + + EXPECT_EQ(en.enable.dbg_fd, static_cast(notifier)); // substituted + EXPECT_EQ(in_handle_out, -1); // adopted + EXPECT_NE(fcntl(notifier, F_GETFD), -1); // still open (session owns it) + + EXPECT_EQ(disable(), 0); // releases the adopted fd (asserted in its own test) +} + +// DISABLE releases the fd the daemon owns; the descriptor is closed afterward. +TEST_F(DbgTrapDaemonTest, DisableClosesAdoptedNotifierFd) { + int notifier = eventfd(0, EFD_CLOEXEC); + ASSERT_GE(notifier, 0); + + ASSERT_EQ(enable_with_notifier(notifier, nullptr), 0); + ASSERT_NE(fcntl(notifier, F_GETFD), -1); // open after ENABLE + + ASSERT_EQ(disable(), 0); + + // The daemon owned the transferred fd and closed it on DISABLE. + EXPECT_EQ(fcntl(notifier, F_GETFD), -1); + EXPECT_EQ(errno, EBADF); +} + +// Without a transferred fd (in_handle == -1, e.g. the client passed +// KFD_INVALID_FD), nothing is substituted, so the notifier stays invalid and +// daemon-mode ENABLE is rejected with -EBADF (matching the kernel's fget() +// check) rather than adopting a bogus descriptor. +TEST_F(DbgTrapDaemonTest, EnableWithoutTransferredFdReturnsEbadf) { + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(kClientPid); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = KFD_INVALID_FD; + + int in_handle_out = -2; + EXPECT_EQ(execute(AMDKFD_IOC_DBG_TRAP, &en, sizeof(en), -1, &in_handle_out), -EBADF); + EXPECT_EQ(in_handle_out, -1); // nothing adopted +} + +// Security: a client can name a small, plausible integer in dbg_fd that happens +// to be a *live descriptor in the daemon* while attaching nothing over +// SCM_RIGHTS. The daemon must never interpret that number in its own fd +// namespace (confused deputy): with no transferred fd the dbg_fd is scrubbed to +// KFD_INVALID_FD, so ENABLE is rejected with -EBADF and the daemon's own +// descriptor is neither adopted nor closed. +TEST_F(DbgTrapDaemonTest, EnableWithClientChosenFdNumberIsNotTrustedInDaemonNamespace) { + // A real, live fd in *this* (daemon) process. The client names exactly this + // number in dbg_fd; without the scrub the handler's fcntl() would validate it + // against the daemon's fd table and adopt it. + const int daemon_fd = eventfd(0, EFD_CLOEXEC); + ASSERT_GE(daemon_fd, 0); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(kClientPid); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = static_cast(daemon_fd); // live in the daemon, not the client + + int in_handle_out = -2; + EXPECT_EQ(execute(AMDKFD_IOC_DBG_TRAP, &en, sizeof(en), -1, &in_handle_out), -EBADF); + EXPECT_EQ(in_handle_out, -1); // nothing adopted + + // The daemon's own descriptor was left untouched: not adopted, not closed. + EXPECT_NE(fcntl(daemon_fd, F_GETFD), -1); + ::close(daemon_fd); +} + +// End-to-end: the RemoteDriver client hands the debugger's notifier fd to an +// in-process daemon over SCM_RIGHTS (mirroring tools/rocjitsu's handle_client), +// and the daemon-side rj_vm_execute_as() adopts it. Proven by having the daemon +// write a sentinel through the *transferred* descriptor and reading it back on +// the client's own eventfd — only possible if SCM_RIGHTS delivered a working +// alias of the same kernel object. +TEST_F(DbgTrapDaemonTest, EnableSendsNotifierFdOverScmRights) { + int sv[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0) << strerror(errno); + + constexpr uint64_t kSentinel = 0x0102030405060708ULL; + std::atomic fds_received{0}; + std::atomic in_handle_after{-2}; + std::atomic notifier_cloexec{-1}; + + // Minimal stand-in for the daemon's RPC_IOCTL loop: capture an optional + // SCM_RIGHTS fd on the header (rpc_recv_msg, exactly as tools/rocjitsu does), + // thread it through cmd.in_handle into the real rj_vm_execute_as() path, and + // reclaim it only if the session did not adopt it. jthread so an ASSERT_* + // failure unwinds without std::terminate() on a joinable thread. + std::jthread server([&, server_fd = sv[1]] { + for (;;) { + rocjitsu::RpcHeader hdr{}; + int in_fds[1] = {-1}; + size_t num_in = 1; + if (rocjitsu::rpc_recv_msg(server_fd, &hdr, sizeof(hdr), in_fds, &num_in) <= 0) + break; + int in_fd = (num_in > 0) ? in_fds[0] : -1; + + if (hdr.opcode == rocjitsu::RPC_CLOSE) { + rocjitsu::RpcHeader resp{}; + resp.request_id = hdr.request_id; + rocjitsu::rpc_send_exact(server_fd, &resp, sizeof(resp)); + if (in_fd >= 0) + ::close(in_fd); + break; + } + if (hdr.opcode != rocjitsu::RPC_IOCTL) { + rocjitsu::RpcHeader resp{}; + resp.request_id = hdr.request_id; + rocjitsu::rpc_send_exact(server_fd, &resp, sizeof(resp)); + if (in_fd >= 0) + ::close(in_fd); + continue; + } + + std::vector payload(hdr.payload_bytes); + if (!rocjitsu::rpc_recv_exact(server_fd, payload.data(), hdr.payload_bytes)) { + if (in_fd >= 0) + ::close(in_fd); + break; + } + auto *ireq = reinterpret_cast(payload.data()); + + // Prove the received descriptor is live and aliases the client's eventfd + // by writing a sentinel through it before the handler adopts it. + if (in_fd >= 0) { + fds_received.fetch_add(1); + // rpc_recv_msg passes MSG_CMSG_CLOEXEC, so the transferred notifier must + // arrive close-on-exec and cannot leak through a later exec. + int fd_flags = ::fcntl(in_fd, F_GETFD); + notifier_cloexec.store((fd_flags >= 0 && (fd_flags & FD_CLOEXEC)) ? 1 : 0); + uint64_t s = kSentinel; + [[maybe_unused]] ssize_t w = ::write(in_fd, &s, sizeof(s)); + } + + rj_vm_cmd_t cmd{}; + cmd.cmd = ireq->ioctl_cmd; + cmd.buf = payload.data() + sizeof(rocjitsu::RpcIoctlRequest); + cmd.buf_size = ireq->args_bytes; + cmd.shared_handle = -1; + cmd.in_handle = in_fd; + rj_vm_execute_as(vm_, process_id_, &cmd); + in_handle_after.store(cmd.in_handle); + if (cmd.in_handle >= 0) + ::close(cmd.in_handle); + + rocjitsu::RpcHeader resp{}; + resp.opcode = rocjitsu::RPC_IOCTL; + resp.request_id = hdr.request_id; + resp.result = cmd.result; + resp.payload_bytes = static_cast(cmd.buf_size); + if (!rocjitsu::rpc_send_exact(server_fd, &resp, sizeof(resp))) + break; + if (cmd.buf_size > 0 && !rocjitsu::rpc_send_exact(server_fd, cmd.buf, cmd.buf_size)) + break; + } + ::close(server_fd); + }); + + rocjitsu::RemoteDriver rd(sv[0]); + + // Non-blocking so a failed transfer fails the read below instead of hanging. + int notifier = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + ASSERT_GE(notifier, 0); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = static_cast(kClientPid); + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = static_cast(notifier); + ASSERT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &en), 0); + + // The client sent exactly one fd via SCM_RIGHTS and the session adopted it. + EXPECT_EQ(fds_received.load(), 1); + EXPECT_EQ(in_handle_after.load(), -1); + // The transferred notifier was received close-on-exec (MSG_CMSG_CLOEXEC), so + // it cannot leak through a later exec in the daemon. + EXPECT_EQ(notifier_cloexec.load(), 1); + + // The daemon's write through the transferred fd is visible on our eventfd, + // proving the descriptor was really carried across the process boundary. + uint64_t got = 0; + ASSERT_EQ(::read(notifier, &got, sizeof(got)), static_cast(sizeof(got))) + << "notifier fd was not transferred: " << strerror(errno); + EXPECT_EQ(got, kSentinel); + + // Release the adopted fd through the transport for symmetry. + kfd_ioctl_dbg_trap_args dis{}; + dis.pid = static_cast(kClientPid); + dis.op = KFD_IOC_DBG_TRAP_DISABLE; + EXPECT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &dis), 0); + + rd.close(); // sends RPC_CLOSE so the server loop exits + server.join(); + ::close(notifier); +} + +// A debug session belongs to the debugger that enabled it: a *different* client +// may not drive it (kernel: EPERM). Only the resolved target itself (self-debug) +// or the registered debugger passes the permission gate. +TEST_F(DbgTrapDaemonTest, ForeignClientCannotDriveAnothersSession) { + // Client A (kClientPid) self-enables debug, becoming its own debugger. + int notifier = eventfd(0, EFD_CLOEXEC); + ASSERT_GE(notifier, 0); + ASSERT_EQ(enable_with_notifier(notifier, nullptr), 0); + + // A second, unrelated client B. + constexpr rj_client_pid_t kOtherPid = 5555; + uint32_t other_pid = 0; + ASSERT_EQ(rj_vm_device_open(vm_, kOtherPid, &other_pid), ROCJITSU_STATUS_SUCCESS); + ASSERT_NE(other_pid, 0u); + + // B targets A's session with a non-DISABLE op: rejected with -EPERM. + kfd_ioctl_dbg_trap_args op{}; + op.pid = static_cast(kClientPid); // target = A + op.op = KFD_IOC_DBG_TRAP_SET_EXCEPTIONS_ENABLED; + rj_vm_cmd_t cmd{}; + cmd.cmd = AMDKFD_IOC_DBG_TRAP; + cmd.buf = &op; + cmd.buf_size = sizeof(op); + cmd.shared_handle = -1; + rj_vm_execute_as(vm_, other_pid, &cmd); // caller = B + EXPECT_EQ(cmd.result, -EPERM); + + rj_vm_device_close(vm_, other_pid); + EXPECT_EQ(disable(), 0); // A tears down its session (closes the notifier) +} + +// --- RemoteDriver DBG_TRAP GET_DEVICE_SNAPSHOT response copy-back --- +// +// The client saves the caller's snapshot buffer pointer and capacity +// (num_devices * entry_size) before serialization, then on the response only +// writes it back on success, clamped to that capacity. This guards daemon mode +// against a failed op (e.g. -ENOSYS) mutating caller memory and against a +// daemon-returned count larger than the caller's buffer. + +// One-shot daemon stand-in: read a single RPC_IOCTL, then reply with `result` +// and a response whose inline tail (after the echoed arg struct) is +// `extra_bytes` of `poison`. Does not close `server_fd` (caller owns it). +void serve_one_ioctl_reply(int server_fd, int32_t result, size_t arg_struct_size, + size_t extra_bytes, uint8_t poison) { + rocjitsu::RpcHeader hdr{}; + int in_fds[1] = {-1}; + size_t num_in = 1; + if (rocjitsu::rpc_recv_msg(server_fd, &hdr, sizeof(hdr), in_fds, &num_in) <= 0) + return; + if (in_fds[0] >= 0) + ::close(in_fds[0]); + std::vector req(hdr.payload_bytes); + if (!rocjitsu::rpc_recv_exact(server_fd, req.data(), hdr.payload_bytes)) + return; + + // Response payload = echoed arg struct + poison tail. The client copies the + // first arg_struct_size bytes back into its arg and treats the remainder as + // inline snapshot data to write into the caller's snapshot buffer. + std::vector out(arg_struct_size + extra_bytes); + std::memcpy(out.data(), req.data() + sizeof(rocjitsu::RpcIoctlRequest), arg_struct_size); + std::memset(out.data() + arg_struct_size, poison, extra_bytes); + + rocjitsu::RpcHeader resp{}; + resp.opcode = rocjitsu::RPC_IOCTL; + resp.request_id = hdr.request_id; + resp.result = result; + resp.payload_bytes = static_cast(out.size()); + rocjitsu::rpc_send_exact(server_fd, &resp, sizeof(resp)); + rocjitsu::rpc_send_exact(server_fd, out.data(), out.size()); +} + +// A GET_DEVICE_SNAPSHOT that the daemon fails (result != 0) must not copy the +// response tail into the caller's snapshot buffer, even though the daemon +// returned inline bytes. +TEST(RemoteDriverDbgSnapshotTest, FailedSnapshotLeavesCallerBufferUntouched) { + int sv[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0) << ::strerror(errno); + + constexpr uint32_t kNumDevices = 4; + constexpr uint32_t kEntrySize = 16; + constexpr size_t kCap = static_cast(kNumDevices) * kEntrySize; + constexpr uint8_t kSentinel = 0xAB; + constexpr uint8_t kPoison = 0xCD; + const size_t arg_struct_size = sizeof(kfd_ioctl_dbg_trap_args); + + std::vector caller_buf(kCap, kSentinel); + + std::jthread server([&, server_fd = sv[1]] { + serve_one_ioctl_reply(server_fd, -ENOSYS, arg_struct_size, kCap, kPoison); + ::close(server_fd); + }); + + rocjitsu::RemoteDriver rd(sv[0]); + + kfd_ioctl_dbg_trap_args snap{}; + snap.pid = 4242; + snap.op = KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT; + snap.device_snapshot.num_devices = kNumDevices; + snap.device_snapshot.entry_size = kEntrySize; + snap.device_snapshot.snapshot_buf_ptr = reinterpret_cast(caller_buf.data()); + + EXPECT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &snap), -ENOSYS); + EXPECT_TRUE(std::all_of(caller_buf.begin(), caller_buf.end(), [](uint8_t b) { + return b == kSentinel; + })) << "failed GET_DEVICE_SNAPSHOT mutated caller memory"; + + server.join(); +} + +// On success the copy is clamped to the caller's original capacity +// (num_devices * entry_size); a daemon returning a larger tail cannot overrun +// the caller's buffer. +TEST(RemoteDriverDbgSnapshotTest, SuccessfulSnapshotClampsCopyToCallerCapacity) { + int sv[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0) << ::strerror(errno); + + constexpr uint32_t kNumDevices = 4; + constexpr uint32_t kEntrySize = 16; + constexpr size_t kCap = static_cast(kNumDevices) * kEntrySize; + constexpr size_t kGuard = 32; // tail beyond the declared capacity, must be untouched + constexpr uint8_t kSentinel = 0xAB; + constexpr uint8_t kPoison = 0xCD; + const size_t arg_struct_size = sizeof(kfd_ioctl_dbg_trap_args); + + std::vector caller_buf(kCap + kGuard, kSentinel); + + std::jthread server([&, server_fd = sv[1]] { + // Return MORE inline bytes than the caller's capacity to exercise the clamp. + serve_one_ioctl_reply(server_fd, 0, arg_struct_size, kCap + kGuard, kPoison); + ::close(server_fd); + }); + + rocjitsu::RemoteDriver rd(sv[0]); + + kfd_ioctl_dbg_trap_args snap{}; + snap.pid = 4242; + snap.op = KFD_IOC_DBG_TRAP_GET_DEVICE_SNAPSHOT; + snap.device_snapshot.num_devices = kNumDevices; + snap.device_snapshot.entry_size = kEntrySize; + snap.device_snapshot.snapshot_buf_ptr = reinterpret_cast(caller_buf.data()); + + EXPECT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &snap), 0); + EXPECT_TRUE(std::all_of(caller_buf.begin(), caller_buf.begin() + kCap, [](uint8_t b) { + return b == kPoison; + })) << "successful snapshot did not copy the daemon payload"; + EXPECT_TRUE(std::all_of(caller_buf.begin() + kCap, caller_buf.end(), [](uint8_t b) { + return b == kSentinel; + })) << "snapshot copy overran the caller's declared capacity"; + + server.join(); +} + +// A closed but positive notifier fd cannot be transferred over SCM_RIGHTS: +// sendmsg() rejects it with EBADF at the client. send_ioctl() must surface that +// errno so the interposer reports EBADF, not the EPERM a bare -1 becomes +// (-EPERM == -1). +TEST(RemoteDriverDbgNotifierTest, EnableWithClosedNotifierFdPreservesEbadf) { + int sv[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0) << ::strerror(errno); + + rocjitsu::RemoteDriver rd(sv[0]); + + // A positive fd number that is already closed: a valid-looking dbg_fd the + // SCM_RIGHTS send must reject. Allocated after the driver so its fd number is + // not reused by the driver's internal eventfd before the send. + int dead_fd = ::eventfd(0, EFD_CLOEXEC); + ASSERT_GE(dead_fd, 0); + ASSERT_EQ(::close(dead_fd), 0); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = 4242; + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = static_cast(dead_fd); + + // The transport rejects the closed fd; the caller must see EBADF, not the bare + // -1 that would surface as EPERM. + EXPECT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &en), -EBADF); + + ::close(sv[1]); +} + +// A daemon-path ENABLE that the daemon fails (result != 0) must not copy the +// response tail into the caller's runtime-info buffer, even though the daemon +// returned inline bytes. Mirrors the GET_DEVICE_SNAPSHOT success gate: a +// rejected notifier fd (-EBADF) leaves caller memory untouched, as local mode does. +TEST(RemoteDriverDbgEnableTest, FailedEnableLeavesCallerRuntimeInfoUntouched) { + int sv[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0) << ::strerror(errno); + + constexpr size_t kRinfoSize = sizeof(kfd_runtime_info); + constexpr uint8_t kSentinel = 0xAB; + constexpr uint8_t kPoison = 0xCD; + const size_t arg_struct_size = sizeof(kfd_ioctl_dbg_trap_args); + + std::vector caller_buf(kRinfoSize, kSentinel); + + std::jthread server([&, server_fd = sv[1]] { + serve_one_ioctl_reply(server_fd, -EBADF, arg_struct_size, kRinfoSize, kPoison); + ::close(server_fd); + }); + + rocjitsu::RemoteDriver rd(sv[0]); + + // A valid notifier fd so the SCM_RIGHTS send succeeds and the request reaches + // the daemon, which then fails the op with -EBADF. Allocated after the driver + // so its fd number is not reused by the driver's internal eventfd. + int notifier_fd = ::eventfd(0, EFD_CLOEXEC); + ASSERT_GE(notifier_fd, 0); + + kfd_ioctl_dbg_trap_args en{}; + en.pid = 4242; + en.op = KFD_IOC_DBG_TRAP_ENABLE; + en.enable.dbg_fd = static_cast(notifier_fd); + en.enable.rinfo_size = static_cast(kRinfoSize); + en.enable.rinfo_ptr = reinterpret_cast(caller_buf.data()); + + EXPECT_EQ(rd.ioctl(AMDKFD_IOC_DBG_TRAP, &en), -EBADF); + EXPECT_TRUE(std::all_of(caller_buf.begin(), caller_buf.end(), [](uint8_t b) { + return b == kSentinel; + })) << "failed ENABLE mutated caller runtime-info memory"; + + ::close(notifier_fd); + server.join(); +} + } // namespace diff --git a/emulation/rocjitsu/tools/rocjitsu/main.cpp b/emulation/rocjitsu/tools/rocjitsu/main.cpp index be3c8821dcfc..e24e4c503d76 100644 --- a/emulation/rocjitsu/tools/rocjitsu/main.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/main.cpp @@ -63,8 +63,25 @@ void handle_client(int client_fd, rj_vm_t *vm, pid_t client_pid, std::stop_token while (!stop.stop_requested() && connected) { RpcHeader hdr{}; - if (!rpc_recv_exact(client_fd, &hdr, sizeof(hdr))) - break; + // Capture an optional SCM_RIGHTS fd the client attaches to the message (the + // debugger notifier pipe on DBG_TRAP ENABLE). A received fd is owned here + // and must be closed if not adopted by the debug session. + int in_fd = -1; + { + int in_fds[1] = {-1}; + size_t num_in_fds = 1; + ssize_t hdr_bytes = rpc_recv_msg(client_fd, &hdr, sizeof(hdr), in_fds, &num_in_fds); + if (num_in_fds > 0) + in_fd = in_fds[0]; + // A short read means the peer truncated the header (or closed); drop the + // connection instead of acting on a partial header, reclaiming any fd that + // arrived with it. (rpc_recv_exact did this implicitly; recvmsg does not.) + if (hdr_bytes != static_cast(sizeof(hdr))) { + if (in_fd >= 0) + ::close(in_fd); + break; + } + } switch (hdr.opcode) { case RPC_HANDSHAKE: { @@ -188,7 +205,13 @@ void handle_client(int client_fd, rj_vm_t *vm, pid_t client_pid, std::stop_token cmd.buf = payload.data() + sizeof(RpcIoctlRequest); cmd.buf_size = ioctl_request->args_bytes; cmd.shared_handle = -1; + cmd.in_handle = in_fd; + in_fd = -1; // ownership passes to cmd; execute clears it on adoption rj_vm_execute_as(vm, process_id, &cmd); + // The debug session adopts the notifier fd on success (in_handle cleared); + // reclaim it otherwise. + if (cmd.in_handle >= 0) + ::close(cmd.in_handle); RpcHeader resp{}; resp.opcode = RPC_IOCTL; @@ -215,6 +238,11 @@ void handle_client(int client_fd, rj_vm_t *vm, pid_t client_pid, std::stop_token connected = false; break; } + + // Safety net: reclaim any notifier fd attached to a non-ioctl message (the + // client only sends one on DBG_TRAP ENABLE). + if (in_fd >= 0) + ::close(in_fd); } if (process_id != 0)