blob: 73331e01490809ab1cf0c58df781e65445c2cfaf [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "remoting/host/terminal_session.h"
#include <fcntl.h>
#include <limits.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>
#include <cstdint>
#include <memory>
#include <optional>
#include <string_view>
#include "base/base_paths.h"
#include "base/check.h"
#include "base/containers/span.h"
#include "base/files/file_descriptor_watcher_posix.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_file.h"
#include "base/functional/bind.h"
#include "base/location.h"
#include "base/logging.h"
#include "base/memory/weak_ptr.h"
#include "base/path_service.h"
#include "base/posix/eintr_wrapper.h"
#include "base/process/kill.h"
#include "base/process/launch.h"
#include "base/process/process.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/task/thread_pool.h"
#include "base/thread_annotations.h"
#include "remoting/base/logging.h"
#include "remoting/host/terminal_process_monitor_linux.h"
#include "remoting/host/terminal_session_manager.h"
namespace remoting {
namespace {
constexpr std::string_view kTmx2Path = "/usr/bin/tmx2";
constexpr std::string_view kTmuxSessionPrefix = "chrome-remote-desktop-";
constexpr std::string_view kTmuxSocketName = "chrome-remote-desktop";
std::string GetTmuxSessionName(int32_t id) {
return base::StrCat({kTmuxSessionPrefix, base::NumberToString(id)});
}
base::FilePath FindTmx2Path() {
// Only tmx2 is supported for terminal sessions. It's impossible to have tmx2
// installed without tmux also being installed.
base::FilePath tmx2_path(kTmx2Path);
if (base::PathExists(tmx2_path)) {
return tmx2_path;
}
return base::FilePath();
}
void KillTmuxSession(int32_t id) {
base::FilePath tmx2_path = FindTmx2Path();
if (!tmx2_path.empty()) {
std::vector<std::string> tmux_args = {
tmx2_path.value(), "-L", std::string(kTmuxSocketName),
"kill-session", "-t", GetTmuxSessionName(id)};
base::Process process =
base::LaunchProcess(tmux_args, base::LaunchOptions());
if (process.IsValid()) {
base::EnsureProcessTerminated(std::move(process));
}
}
}
void TerminateProcessInBackground(base::Process process) {
process.Terminate(0, false);
base::EnsureProcessTerminated(std::move(process));
}
std::optional<pid_t> GetTmuxPaneShellPid(int32_t id) {
base::FilePath tmx2_path = FindTmx2Path();
if (tmx2_path.empty()) {
return std::nullopt;
}
std::string output;
std::vector<std::string> args = {
tmx2_path.value(), "-L", std::string(kTmuxSocketName),
"display-message", "-p", "-t",
GetTmuxSessionName(id), "-F", "#{pane_pid}"};
if (!base::GetAppOutput(args, &output)) {
return std::nullopt;
}
output = base::TrimWhitespaceASCII(output, base::TRIM_ALL);
int int_pid;
if (base::StringToInt(output, &int_pid) && int_pid > 0) {
return static_cast<pid_t>(int_pid);
}
return std::nullopt;
}
std::string GetTmuxScrollback(int32_t id) {
base::FilePath tmx2_path = FindTmx2Path();
if (tmx2_path.empty()) {
return std::string();
}
// Capture full pane history with -epJ flags.
std::string scrollback_output;
std::vector<std::string> args = {
tmx2_path.value(), "-L", std::string(kTmuxSocketName),
"capture-pane", "-epJ",
"-S", "-",
"-t", GetTmuxSessionName(id)};
if (!base::GetAppOutput(args, &scrollback_output) ||
scrollback_output.empty()) {
return std::string();
}
// Trim trailing newlines so the terminal does not scroll an extra line
// (which would push the top visible line into scrollback and cause a
// duplicate line when tmux redraws).
std::string_view trimmed =
base::TrimString(scrollback_output, "\r\n", base::TRIM_TRAILING);
if (trimmed.empty()) {
return std::string();
}
scrollback_output.resize(trimmed.size());
// Normalize line endings to CRLF. Direct callback output bypasses the
// PTY's automatic ONLCR translation, causing terminal staircasing.
//
// A two-step replacement (\r\n -> \n, then \n -> \r\n) is used to ensure
// idempotency: it converts any mixed or standalone '\n' to '\r\n' while
// preventing already-CRLF lines from turning into duplicate carriage returns
// (\r\r\n), unlike naive ONLCR prefixing.
base::ReplaceSubstringsAfterOffset(&scrollback_output, 0, "\r\n", "\n");
base::ReplaceSubstringsAfterOffset(&scrollback_output, 0, "\n", "\r\n");
return scrollback_output;
}
// PreExecDelegate to set up the PTY session in the child process. It creates
// a new session leader and attaches the process to the PTY.
class TerminalPreExecDelegate : public base::LaunchOptions::PreExecDelegate {
public:
TerminalPreExecDelegate() = default;
~TerminalPreExecDelegate() override = default;
void RunAsyncSafe() override {
setsid();
ioctl(STDIN_FILENO, TIOCSCTTY, 0);
}
};
base::Process LaunchShellProcess(int32_t id, base::ScopedFD subsidiary_fd) {
base::FilePath tmx2_path = FindTmx2Path();
// If tmx2 is not available, then we cannot launch the terminal session.
if (tmx2_path.empty()) {
LOG(ERROR)
<< "tmx2 binary not found. Cannot launch terminal session.";
return base::Process();
}
std::vector<std::string> tmux_cmd = {
tmx2_path.value(),
"-L", std::string(kTmuxSocketName),
"set-option", "-s", "terminal-overrides", "xterm*:smcup@:rmcup@", ";",
"new-session", "-A", "-s", GetTmuxSessionName(id),
"-e", base::StrCat({"CRD_TERMINAL_ID=", base::NumberToString(id)}), ";",
"set-option", "set-titles", "on", ";",
"set-option", "set-titles-string", "#T", ";",
"set-option", "-g", "status", "off", ";",
"set-option", "-g", "mouse", "off"
};
base::LaunchOptions options;
base::FilePath home_dir;
if (base::PathService::Get(base::DIR_HOME, &home_dir)) {
options.current_directory = std::move(home_dir);
}
options.allow_new_privs = true;
options.fds_to_remap.emplace_back(subsidiary_fd.get(), STDIN_FILENO);
options.fds_to_remap.emplace_back(subsidiary_fd.get(), STDOUT_FILENO);
options.fds_to_remap.emplace_back(subsidiary_fd.get(), STDERR_FILENO);
TerminalPreExecDelegate delegate;
options.pre_exec_delegate = &delegate;
options.environment["TERM"] = "xterm-256color";
return base::LaunchProcess(tmux_cmd, options);
}
class TerminalSessionLinux : public TerminalSession {
public:
TerminalSessionLinux(
TerminalSessionManager::OutputCallback output_cb,
TerminalSessionManager::ExitCallback exit_cb,
TerminalSessionManager::ProcessInfoCallback process_info_cb,
int32_t id)
: output_callback_(std::move(output_cb)),
exit_callback_(std::move(exit_cb)),
process_info_callback_(std::move(process_info_cb)),
id_(id),
writer_task_runner_(base::ThreadPool::CreateSequencedTaskRunner(
{base::MayBlock(), base::TaskPriority::USER_VISIBLE,
base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN})) {}
~TerminalSessionLinux() override { CleanupLocalSession(); }
// Start the terminal session. This will start a new PTY session and launch a
// bash process in the subsidiary end of the PTY.
bool Start() override {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
base::ScopedFD pty_fd(
HANDLE_EINTR(posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC)));
if (!pty_fd.is_valid()) {
PLOG(ERROR) << "posix_openpt failed";
return false;
}
if (grantpt(pty_fd.get()) != 0) {
PLOG(ERROR) << "grantpt failed";
return false;
}
if (unlockpt(pty_fd.get()) != 0) {
PLOG(ERROR) << "unlockpt failed";
return false;
}
char subsidiary_name[TTY_NAME_MAX];
if (ptsname_r(pty_fd.get(), subsidiary_name, sizeof(subsidiary_name)) !=
0) {
PLOG(ERROR) << "ptsname_r failed";
return false;
}
base::ScopedFD subsidiary_fd(
HANDLE_EINTR(open(subsidiary_name, O_RDWR | O_NOCTTY | O_CLOEXEC)));
if (!subsidiary_fd.is_valid()) {
PLOG(ERROR) << "open subsidiary_fd failed";
return false;
}
struct termios ios;
if (tcgetattr(subsidiary_fd.get(), &ios) == 0) {
ios.c_iflag |= IUTF8;
tcsetattr(subsidiary_fd.get(), TCSANOW, &ios);
}
pty_fd_ = std::move(pty_fd);
writer_task_runner_->PostTaskAndReplyWithResult(
FROM_HERE,
base::BindOnce(&LaunchShellProcess, id_, std::move(subsidiary_fd)),
base::BindOnce(
[](base::WeakPtr<TerminalSessionLinux> weak_this,
scoped_refptr<base::SequencedTaskRunner> writer_task_runner,
base::Process process) {
if (weak_this) {
weak_this->OnProcessLaunched(std::move(process));
} else if (process.IsValid()) {
writer_task_runner->PostTask(
FROM_HERE, base::BindOnce(&TerminateProcessInBackground,
std::move(process)));
}
},
weak_factory_.GetWeakPtr(), writer_task_runner_));
return true;
}
void OnProcessLaunched(base::Process process) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!process.IsValid()) {
LOG(ERROR) << "Failed to launch terminal shell process asynchronously";
CleanupLocalSession();
if (exit_callback_) {
std::move(exit_callback_).Run(id_);
}
return;
}
// If the session was detached or terminated before the process was
// launched, terminate the process and return.
if (detached_ || terminated_) {
if (writer_task_runner_) {
writer_task_runner_->PostTask(
FROM_HERE, base::BindOnce(&TerminateProcessInBackground,
std::move(process)));
}
return;
}
// process_ will never be valid here since it's only set by
// OnProcessLaunched(), which is only called once by Start().
CHECK(!process_.IsValid());
process_ = std::move(process);
// Asynchronously retrieve and forward existing scrollback history before
// starting to watch live PTY output to avoid stream interleaving. Sequence
// through writer_task_runner_ to preserve order with lifecycle commands.
writer_task_runner_->PostTaskAndReplyWithResult(
FROM_HERE,
base::BindOnce(&GetTmuxScrollback, id_),
base::BindOnce(&TerminalSessionLinux::OnScrollbackRetrieved,
weak_factory_.GetWeakPtr()));
}
static void WriteToPtyManager(int fd, std::string payload) {
base::span<const char> remaining(payload);
while (!remaining.empty()) {
ssize_t bytes_written =
HANDLE_EINTR(write(fd, remaining.data(), remaining.size()));
if (bytes_written <= 0) {
PLOG(WARNING) << "write to PTY manager failed";
return;
}
remaining = remaining.subspan(static_cast<size_t>(bytes_written));
}
}
// Write terminal input to the Manager end of PTY.
void Write(const std::string& data) override {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!pty_fd_.is_valid()) {
LOG(ERROR)
<< "Write called before successful Start() or after Terminate()";
return;
}
// Post the write task to the dedicated sequenced task runner.
// Pass the raw file descriptor integer from pty_fd_.
writer_task_runner_->PostTask(
FROM_HERE, base::BindOnce(&TerminalSessionLinux::WriteToPtyManager,
pty_fd_.get(), data));
}
// Resizes the terminal window (rows and columns) of the PTY.
void Resize(uint32_t width, uint32_t height) override {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!pty_fd_.is_valid()) {
LOG(WARNING) << "Resize called with invalid pty_fd_";
return;
}
struct winsize ws;
ws.ws_col = width;
ws.ws_row = height;
ws.ws_xpixel = 0;
ws.ws_ypixel = 0;
if (ioctl(pty_fd_.get(), TIOCSWINSZ, &ws) != 0) {
PLOG(ERROR) << "ioctl(TIOCSWINSZ) failed";
}
}
// Terminates the terminal session and stops the output watcher.
// Called when the user specifically closes a terminal session.
void Terminate() override {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (terminated_) {
return;
}
terminated_ = true;
if (writer_task_runner_) {
writer_task_runner_->PostTask(
FROM_HERE, base::BindOnce(&KillTmuxSession, id_));
}
CleanupLocalSession();
}
// Detaches from the terminal session destroying the terminal emulator process
// but leaving the tmux server session intact to allow for reconnection.
void Detach() override {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
CleanupLocalSession();
}
private:
void CleanupLocalSession() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (detached_) {
return;
}
detached_ = true;
output_watcher_.reset();
process_monitor_.reset();
if (process_.IsValid() && writer_task_runner_) {
writer_task_runner_->PostTask(
FROM_HERE, base::BindOnce(&TerminateProcessInBackground,
std::move(process_)));
}
if (pty_fd_.is_valid() && writer_task_runner_) {
// Post the destruction of pty_fd_ to the writer task runner
// to ensure it's closed after all pending writes are done.
writer_task_runner_->PostTask(
FROM_HERE, base::BindOnce([](base::ScopedFD fd) { fd.reset(); },
std::move(pty_fd_)));
}
}
// Watches the PTY manager file descriptor for readable data.
void WatchOutput() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (!pty_fd_.is_valid()) {
LOG(ERROR) << "WatchOutput called with invalid pty_fd_";
return;
}
output_watcher_ = base::FileDescriptorWatcher::WatchReadable(
pty_fd_.get(),
base::BindRepeating(&TerminalSessionLinux::OnOutputCanRead,
weak_factory_.GetWeakPtr()));
}
void OnOutputCanRead() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
char buffer[4096];
if (!pty_fd_.is_valid()) {
LOG(ERROR) << "OnOutputCanRead called with invalid pty_fd_";
output_watcher_.reset();
if (exit_callback_) {
std::move(exit_callback_).Run(id_);
}
return;
}
// If the PTY pipe has more than 4k of data, this will read it in chunks.
// The FileDescriptorWatcher will notify again to read the remaining data.
ssize_t bytes_read =
HANDLE_EINTR(read(pty_fd_.get(), buffer, sizeof(buffer)));
if (bytes_read > 0) {
// Retrieve the shell PID if it hasn't been retrieved yet.
// This is done once when output is first received since that means that
// the tmux pane has been successfully created.
if (!shell_pid_retrieval_started_) {
shell_pid_retrieval_started_ = true;
base::ThreadPool::PostTaskAndReplyWithResult(
FROM_HERE,
{base::MayBlock(), base::TaskPriority::USER_VISIBLE,
base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN},
base::BindOnce(&GetTmuxPaneShellPid, id_),
base::BindOnce(&TerminalSessionLinux::OnShellPidRetrieved,
weak_factory_.GetWeakPtr()));
}
output_callback_.Run(id_, std::string(buffer, bytes_read));
} else {
if (bytes_read < 0) {
PLOG(ERROR) << "read from PTY manager failed";
} else {
HOST_LOG << "PTY manager reached EOF - normal exit";
}
output_watcher_.reset();
if (exit_callback_) {
std::move(exit_callback_).Run(id_);
}
}
}
void OnScrollbackRetrieved(std::string scrollback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (detached_ || terminated_) {
HOST_LOG
<< "OnScrollbackRetrieved called after detach or terminate, ignoring";
return;
}
base::WeakPtr<TerminalSessionLinux> weak_this = weak_factory_.GetWeakPtr();
if (!scrollback.empty() && output_callback_) {
output_callback_.Run(id_, std::move(scrollback));
}
// Re-verify session validity and state before starting output watcher in
// case output_callback_ synchronously destroyed the instance
// or triggered Detach()/Terminate().
if (!weak_this || weak_this->detached_ || weak_this->terminated_) {
return;
}
WatchOutput();
}
void OnShellPidRetrieved(std::optional<pid_t> pid) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (detached_ || terminated_) {
HOST_LOG << "OnShellPidRetrieved called after detach or terminate, "
"ignoring";
return;
}
if (pid) {
shell_pid_ = *pid;
HOST_LOG << "Retrieved shell PID " << *pid
<< " for terminal session " << id_;
if (process_info_callback_) {
process_monitor_ = std::make_unique<TerminalProcessMonitorLinux>(
*pid,
base::BindRepeating(&TerminalSessionLinux::OnProcessInfoChanged,
weak_factory_.GetWeakPtr()));
process_monitor_->StartPolling();
}
} else {
LOG(WARNING) << "Failed to retrieve shell PID for tmux terminal " << id_;
}
}
void OnProcessInfoChanged(
bool is_active,
const std::optional<std::string>& process_name) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
if (detached_ || terminated_) {
HOST_LOG << "OnProcessInfoChanged called after detach or terminate, "
"ignoring";
return;
}
if (process_info_callback_) {
std::string_view process_name_view = "";
if (process_name) {
process_name_view = *process_name;
}
process_info_callback_.Run(id_, is_active, process_name_view);
}
}
base::ScopedFD pty_fd_ GUARDED_BY_CONTEXT(sequence_checker_);
base::Process process_ GUARDED_BY_CONTEXT(sequence_checker_);
std::unique_ptr<base::FileDescriptorWatcher::Controller> output_watcher_
GUARDED_BY_CONTEXT(sequence_checker_);
std::unique_ptr<TerminalProcessMonitorLinux> process_monitor_
GUARDED_BY_CONTEXT(sequence_checker_);
TerminalSessionManager::OutputCallback output_callback_;
TerminalSessionManager::ExitCallback exit_callback_
GUARDED_BY_CONTEXT(sequence_checker_);
TerminalSessionManager::ProcessInfoCallback process_info_callback_
GUARDED_BY_CONTEXT(sequence_checker_);
int32_t id_;
bool detached_ = false;
bool terminated_ = false;
scoped_refptr<base::SequencedTaskRunner> writer_task_runner_;
std::optional<pid_t> shell_pid_ GUARDED_BY_CONTEXT(sequence_checker_);
bool shell_pid_retrieval_started_
GUARDED_BY_CONTEXT(sequence_checker_) = false;
SEQUENCE_CHECKER(sequence_checker_);
base::WeakPtrFactory<TerminalSessionLinux> weak_factory_{this};
};
} // namespace
// static
std::unique_ptr<TerminalSession> TerminalSession::Create(
TerminalSessionManager::OutputCallback output_cb,
TerminalSessionManager::ExitCallback exit_cb,
TerminalSessionManager::ProcessInfoCallback process_info_cb,
int32_t id) {
return std::make_unique<TerminalSessionLinux>(
std::move(output_cb), std::move(exit_cb), std::move(process_info_cb),
id);
}
// static
std::vector<int32_t> TerminalSession::GetPersistentTerminalIds() {
// This is a blocking call (uses PathExists and GetAppOutput).
base::FilePath tmx2_path = FindTmx2Path();
if (tmx2_path.empty()) {
return {};
}
std::string output;
std::vector<std::string> args = {
tmx2_path.value(), "-L", std::string(kTmuxSocketName),
"list-sessions", "-F", "#{session_name}"};
if (!base::GetAppOutput(args, &output)) {
return {};
}
std::vector<std::string_view> lines = base::SplitStringPiece(
output, "\n", base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY);
std::vector<int32_t> restored_ids;
for (std::string_view line : lines) {
if (line.starts_with(kTmuxSessionPrefix)) {
std::string_view id_str = line.substr(kTmuxSessionPrefix.size());
int32_t id;
if (base::StringToInt(id_str, &id)) {
restored_ids.push_back(id);
}
}
}
return restored_ids;
}
} // namespace remoting