Skip to content

[lldb-dap] Implement runInTerminal for Windows #121269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lldb/packages/Python/lldbsuite/test/dotest.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ def setupSysPath():

lldbDir = os.path.dirname(lldbtest_config.lldbExec)

lldbDAPExec = os.path.join(lldbDir, "lldb-dap")
lldbDAPExec = os.path.join(lldbDir, "lldb-dap.exe" if os.name == "nt" else "lldb-dap")
if is_exe(lldbDAPExec):
os.environ["LLDBDAP_EXEC"] = lldbDAPExec

Expand Down
2 changes: 1 addition & 1 deletion lldb/test/API/tools/lldb-dap/runInTerminal/Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
C_SOURCES := main.c
CXX_SOURCES := main.cpp

include Makefile.rules
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ def isTestSupported(self):
except:
return False

@skipIfWindows
@skipIf(oslist=["linux"], archs=no_match(["x86_64"]))
def test_runInTerminal(self):
if not self.isTestSupported():
Expand All @@ -53,7 +52,7 @@ def test_runInTerminal(self):
launch the inferior with the correct environment variables and arguments.
"""
program = self.getBuildArtifact("a.out")
source = "main.c"
source = "main.cpp"
self.build_and_launch(
program, runInTerminal=True, args=["foobar"], env=["FOO=bar"]
)
Expand Down Expand Up @@ -113,7 +112,6 @@ def test_runInTerminalWithObjectEnv(self):
self.assertIn("FOO", request_envs)
self.assertEqual("BAR", request_envs["FOO"])

@skipIfWindows
@skipIf(oslist=["linux"], archs=no_match(["x86_64"]))
def test_runInTerminalInvalidTarget(self):
if not self.isTestSupported():
Expand All @@ -132,7 +130,6 @@ def test_runInTerminalInvalidTarget(self):
response["message"],
)

@skipIfWindows
@skipIf(oslist=["linux"], archs=no_match(["x86_64"]))
def test_missingArgInRunInTerminalLauncher(self):
if not self.isTestSupported():
Expand Down
11 changes: 0 additions & 11 deletions lldb/test/API/tools/lldb-dap/runInTerminal/main.c

This file was deleted.

13 changes: 13 additions & 0 deletions lldb/test/API/tools/lldb-dap/runInTerminal/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#include <chrono>
#include <cstdlib>
#include <thread>

using namespace std;

int main(int argc, char *argv[]) {
const char *foo = getenv("FOO");
for (int counter = 1;; counter++) {
this_thread::sleep_for(chrono::seconds(1)); // breakpoint
}
return 0;
}
131 changes: 112 additions & 19 deletions lldb/tools/lldb-dap/FifoFiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@

#include "FifoFiles.h"
#include "JSONUtils.h"
#include "llvm/Support/FileSystem.h"
#include "llvm/Support/Path.h"
#include "llvm/Support/WindowsError.h"

#if !defined(_WIN32)
#if defined(_WIN32)
#include <Windows.h>
#include <fcntl.h>
#include <io.h>
#else
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
Expand All @@ -24,41 +31,105 @@ using namespace llvm;

namespace lldb_dap {

FifoFile::FifoFile(StringRef path) : m_path(path) {}
FifoFile::FifoFile(std::string path, FILE *f) : m_path(path), m_file(f) {}

Expected<FifoFile> FifoFile::create(StringRef path) {
auto file = fopen(path.data(), "r+");
if (file == nullptr)
return createStringError(inconvertibleErrorCode(),
"Failed to open fifo file " + path);
if (setvbuf(file, NULL, _IONBF, 0))
return createStringError(inconvertibleErrorCode(),
"Failed to setvbuf on fifo file " + path);
return FifoFile(path, file);
}

FifoFile::FifoFile(StringRef path, FILE *f) : m_path(path), m_file(f) {}

FifoFile::FifoFile(FifoFile &&other)
: m_path(other.m_path), m_file(other.m_file) {
other.m_path.clear();
other.m_file = nullptr;
}
FifoFile::~FifoFile() {
if (m_file)
fclose(m_file);
#if !defined(_WIN32)
// Unreferenced named pipes are deleted automatically on Win32
unlink(m_path.c_str());
#endif
}

Expected<std::shared_ptr<FifoFile>> CreateFifoFile(StringRef path) {
#if defined(_WIN32)
return createStringError(inconvertibleErrorCode(), "Unimplemented");
// This probably belongs to llvm::sys::fs as another FSEntity type
Error createUniqueNamedPipe(const Twine &prefix, StringRef suffix,
int &result_fd,
SmallVectorImpl<char> &result_path) {
std::error_code EC;
#ifdef _WIN32
const char *middle = suffix.empty() ? "-%%%%%%" : "-%%%%%%.";
EC = sys::fs::getPotentiallyUniqueFileName(
"\\\\.\\pipe\\LOCAL\\" + prefix + middle + suffix, result_path);
#else
EC = sys::fs::getPotentiallyUniqueTempFileName(prefix, suffix, result_path);
#endif
if (EC)
return errorCodeToError(EC);
result_path.push_back(0);
const char *path = result_path.data();
#ifdef _WIN32
HANDLE h = ::CreateNamedPipeA(
path, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE,
Copy link
Contributor

Choose a reason for hiding this comment

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

PIPE_ACCESS_DUPLEX I think this is the part that's been confusing me.

I looked up how pipes work on Windows a bit more and it looks like the pipe is being opened in duplex mode, which allows it to read and write. A fifo on linux or macOS is unidirectional, when its opened for reading it blocks until the file is also opened for writing and vice versa. This causes lldb-dap to have some synchronization points between the two processes as they're reading or writing to the fifo file.

However, with this change it looks like we're attempt to open the file in r+ mode on line 37. I'm worried this would not work as expected or is contributing to the need for some of the rewind calls in the ReadJSON method. It is possible to open a fifo in read/write mode but that is really trick to not deadlock the process by having it wait on itself accidentally.

Should we instead switch to a communication channel that supports reading and writing for all platforms? For example, we could open a socket for local communication and set the port of the socket instead of using a fifo. Sockets do support both reading and writing on all platforms and we have some existing helpers for supporting sockets already.

Is that something you might have a chance to look into? If not, I could take some time to look at converting this to use sockets in the near future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

when its opened for reading it blocks until the file is also opened for writing and vice versa

This is implemented with ConnectNamedPipe in FifoFileIO::WaitForPeer after creation. WaitNamedPipe was not needed as the server side would already be around by the time the client tries to connect.

deadlock the process by having it wait on itself accidentally

Intra-process deadlock (waiting on its own pending output) should not be possible, since on Windows a process cannot even read back what it wrote into a named pipe from the same handle. The data flow is bidirectional, but can only be consumed by the other end.

Inter-process deadlock, with the exception of buffering, is also rather unlikely. Current protocol over the pipe is strictly synchronous and sequential, or "half-duplex" in some sense -- there are no overlapping transmissions; in other words, the process will not transmit before expected response is fully received, and will not block on read before it sends out the message.

Buffering can still cause deadlocks. Without the rewind, the process proceeds to wait for the response after write, but the payload has somehow not fully arrived at the other end, and the peer as a result does not respond yet. I speculated it to be due to libc buffering, but did not manage to penetrate through MS CRT code to verify.

Should we instead switch to a communication channel that supports reading and writing for all platforms?

Using sockets feels a bit like overkill, but would definitely make code more uniform across platforms I suppose. I can give it a try if you deem it more maintainable.

we have some existing helpers for supporting sockets already.

As an aside, I remember I did also attempt to retrofit the use case into lldb_private::Pipe, but gave up because duplex was too hard to shoehorn into the existing abstraction, and would have very limited utility even if implemented.

Is that something you might have a chance to look into?

I am not too familiar with that so unable to give guarantees, but let me give it a try.
Also unsure if the socket rewrite is going to make it for LLVM 21 feature freeze in July.

PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, 512, 512, 0, NULL);
if (h == INVALID_HANDLE_VALUE)
return createStringError(mapLastWindowsError(), "CreateNamedPipe");
result_fd = _open_osfhandle((intptr_t)h, _O_TEXT | _O_RDWR);
if (result_fd == -1)
return createStringError(mapLastWindowsError(), "_open_osfhandle");
#else
if (int err = mkfifo(path.data(), 0600))
return createStringError(std::error_code(err, std::generic_category()),
"Couldn't create fifo file: %s", path.data());
return std::make_shared<FifoFile>(path);
if (mkfifo(path, 0600) == -1)
return createStringError(std::error_code(errno, std::generic_category()),
"mkfifo");
EC = openFileForWrite(result_path, result_fd, sys::fs::CD_OpenExisting,
sys::fs::OF_None, 0600);
if (EC)
return errorCodeToError(EC);
#endif
result_path.pop_back();
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason why this is being removed from the end of the path? I think thats the \0 char (pushed on line 75).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it is the NUL. It would be encoded into JSON if not popped.

I think it might have always been buggy and just happened to work, as NUL termination from StringRef::data is explicitly not guaranteed.

return Error::success();
}

FifoFileIO::FifoFileIO(StringRef fifo_file, StringRef other_endpoint_name)
: m_fifo_file(fifo_file), m_other_endpoint_name(other_endpoint_name) {}
FifoFileIO::FifoFileIO(FifoFile &&fifo_file, StringRef other_endpoint_name)
: m_fifo_file(std::move(fifo_file)),
m_other_endpoint_name(other_endpoint_name) {}

Expected<json::Value> FifoFileIO::ReadJSON(std::chrono::milliseconds timeout) {
// We use a pointer for this future, because otherwise its normal destructor
// would wait for the getline to end, rendering the timeout useless.
std::optional<std::string> line;
std::future<void> *future =
new std::future<void>(std::async(std::launch::async, [&]() {
std::ifstream reader(m_fifo_file, std::ifstream::in);
std::string buffer;
std::getline(reader, buffer);
if (!buffer.empty())
line = buffer;
rewind(m_fifo_file.m_file);
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to rewind the file? Shouldn't the position be at the beginning of the file already?

Copy link
Contributor Author

@SuibianP SuibianP Feb 5, 2025

Choose a reason for hiding this comment

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

It seemed to help on Windows, but I did not investigate further, as it is technically also required by ISO C standard (C23 draft N3220 §7.23.5.3.7) which C++ delegate to for C libraries ([library.c]).

When a file is opened with update mode (’+’ as the second or third character in the previously
described list of mode argument values), both input and output may be performed on the associated
stream. However, output shall not be directly followed by input without an intervening call to the
fflush function or to a file positioning function (fseek, fsetpos, or rewind), and input shall not
be directly followed by output without an intervening call to a file positioning function, unless the
input operation encounters end-of-file. Opening (or creating) a text file with update mode may
instead open (or create) a binary stream in some implementations.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the flow of the logic here is:

  • lldb-dap (pid 1) opens the fifo
  • pid 1 sends sends the runInTerminal command.
  • pid 1 reads from the fifo (waiting for data)
  • lldb-dap --launch-target (pid 2) starts
  • pid 2 opens the fifo
  • pid 2 spawns the new process
  • pid 2 writes to the fifo (something like {"pid":2}\n)
  • pid 1 finishes the initial read from the fifo
  • pid 1 lldb attaches to the process started by pid 2

This should mean that the parent side only reads from the file and the child side only writes to the file. Additionally, I think they're only each performing 1 write/read on each side so their respective file positions should still be at the beginning of the file after opening.

Copy link
Contributor Author

@SuibianP SuibianP Feb 21, 2025

Choose a reason for hiding this comment

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

The DA side ("PID 1") sends a RunInTerminalMessageDidAttach message in RunInTerminalDebugAdapterCommChannel::NotifyDidAttach when it attaches to the target PID received from the launcher. This is also why the FIFO must be PIPE_ACCESS_DUPLEX. The launcher process waits for this message before proceeding with executing the target.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think seeking isn't support on a fifo (lseek would return ESPIPE, see man 2 lseek or the Errors section of https://man7.org/linux/man-pages/man2/lseek.2.html). So I'm not sure if you need to do these rewind on non-windows platforms.

Copy link
Contributor Author

@SuibianP SuibianP Feb 27, 2025

Choose a reason for hiding this comment

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

Practically it should not be needed, but I am tempted to leave the rewind there. It helps with standard compliance and does not harm anyways as long as its errno is ignored. There might as well be other non-Windows platforms requiring this invocation to properly function.

Citing C89 rationale §4.9.5.3

A change of input/output direction on an update file is only allowed following a fsetpos, fseek, rewind, or fflush operation, since these are precisely the functions which assure that the I/O buffer has been flushed.

FIFO can also possibly have buffering, and the requirement makes sense. I suppose the timeout I observed on Windows with rewind omitted is due to a deadlock condition created by unflushed buffer.

/*
* The types of messages passing through the fifo are:
* {"kind": "pid", "pid": 9223372036854775807}
* {"kind": "didAttach"}
* {"kind": "error", "error": "error message"}
*
* 512 characters should be more than enough.
*/
constexpr size_t buffer_size = 512;
char buffer[buffer_size];
char *ptr = fgets(buffer, buffer_size, m_fifo_file.m_file);
if (ptr == nullptr || *ptr == 0)
return;
size_t len = strlen(buffer);
if (len <= 1)
return;
buffer[len - 1] = '\0'; // remove newline
line = buffer;
Comment on lines +121 to +129
Copy link
Contributor

Choose a reason for hiding this comment

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

The previous version would get a full line, however this new version will read until the buffer is full or EOF. The JSON used is smaller than the buffer size, so this should be okay, but if the JSON value was changed then this could be an issue.

Should we read until EOF? Or Should we read until we find a newline specifically?

Copy link
Contributor Author

@SuibianP SuibianP Feb 21, 2025

Choose a reason for hiding this comment

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

We can reinvent POSIX getline, but it is probably not worth the effort. Any JSON schema change that exceeds the buffer length will absolutely get truncated without actually overrunning the buffer, break the test visibly and get caught during review.

}));
if (future->wait_for(timeout) == std::future_status::timeout || !line)

if (future->wait_for(timeout) == std::future_status::timeout)
// Indeed this is a leak, but it's intentional. "future" obj destructor
// will block on waiting for the worker thread to join. And the worker
// thread might be stuck in blocking I/O. Intentionally leaking the obj
Expand All @@ -69,6 +140,11 @@ Expected<json::Value> FifoFileIO::ReadJSON(std::chrono::milliseconds timeout) {
return createStringError(inconvertibleErrorCode(),
"Timed out trying to get messages from the " +
m_other_endpoint_name);
if (!line) {
return createStringError(inconvertibleErrorCode(),
"Failed to get messages from the " +
m_other_endpoint_name);
}
delete future;
return json::parse(*line);
}
Expand All @@ -78,8 +154,12 @@ Error FifoFileIO::SendJSON(const json::Value &json,
bool done = false;
std::future<void> *future =
new std::future<void>(std::async(std::launch::async, [&]() {
std::ofstream writer(m_fifo_file, std::ofstream::out);
writer << JSONToString(json) << std::endl;
rewind(m_fifo_file.m_file);
auto payload = JSONToString(json);
if (fputs(payload.c_str(), m_fifo_file.m_file) == EOF ||
fputc('\n', m_fifo_file.m_file) == EOF) {
return;
}
done = true;
}));
if (future->wait_for(timeout) == std::future_status::timeout || !done) {
Expand All @@ -98,4 +178,17 @@ Error FifoFileIO::SendJSON(const json::Value &json,
return Error::success();
}

Error FifoFileIO::WaitForPeer() {
#ifdef _WIN32
if (!::ConnectNamedPipe((HANDLE)_get_osfhandle(fileno(m_fifo_file.m_file)),
NULL) &&
GetLastError() != ERROR_PIPE_CONNECTED) {
return createStringError(mapLastWindowsError(),
"Failed to connect to the " +
m_other_endpoint_name);
}
#endif
return Error::success();
}

} // namespace lldb_dap
35 changes: 25 additions & 10 deletions lldb/tools/lldb-dap/FifoFiles.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,44 @@

#include "llvm/Support/Error.h"
#include "llvm/Support/JSON.h"
#include "llvm/Support/raw_ostream.h"

#include <chrono>
#include <fstream>

namespace lldb_dap {

/// Struct that controls the life of a fifo file in the filesystem.
///
/// The file is destroyed when the destructor is invoked.
struct FifoFile {
FifoFile(llvm::StringRef path);
FifoFile(FifoFile &&other);

FifoFile(const FifoFile &) = delete;
FifoFile &operator=(const FifoFile &) = delete;

~FifoFile();

static llvm::Expected<FifoFile> create(llvm::StringRef path);
FifoFile(llvm::StringRef path, FILE *f);

std::string m_path;
FILE *m_file;

private:
FifoFile(std::string path, FILE *f);
};

/// Create a fifo file in the filesystem.
/// Create and open a named pipe with a unique name.
///
/// \param[in] path
/// The path for the fifo file.
/// The arguments have identical meanings to those of
/// llvm::sys::fs::createTemporaryFile.
///
/// \return
/// A \a std::shared_ptr<FifoFile> if the file could be created, or an
/// \a llvm::Error in case of failures.
llvm::Expected<std::shared_ptr<FifoFile>> CreateFifoFile(llvm::StringRef path);
/// Note that the resulting filename is further prepended by \c \\.\pipe\\LOCAL\
/// on Windows and the native temp directory on other platforms.
llvm::Error createUniqueNamedPipe(const llvm::Twine &prefix,
llvm::StringRef suffix, int &result_fd,
llvm::SmallVectorImpl<char> &result_path);

class FifoFileIO {
public:
Expand All @@ -45,7 +58,7 @@ class FifoFileIO {
/// \param[in] other_endpoint_name
/// A human readable name for the other endpoint that will communicate
/// using this file. This is used for error messages.
FifoFileIO(llvm::StringRef fifo_file, llvm::StringRef other_endpoint_name);
FifoFileIO(FifoFile &&fifo_file, llvm::StringRef other_endpoint_name);

/// Read the next JSON object from the underlying input fifo file.
///
Expand Down Expand Up @@ -75,8 +88,10 @@ class FifoFileIO {
const llvm::json::Value &json,
std::chrono::milliseconds timeout = std::chrono::milliseconds(20000));

llvm::Error WaitForPeer();

private:
std::string m_fifo_file;
FifoFile m_fifo_file;
std::string m_other_endpoint_name;
};

Expand Down
8 changes: 6 additions & 2 deletions lldb/tools/lldb-dap/Handler/RequestHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ RunInTerminal(DAP &dap, const protocol::LaunchRequestArguments &arguments) {
if (!comm_file_or_err)
return comm_file_or_err.takeError();
FifoFile &comm_file = *comm_file_or_err.get();
std::string comm_filename = comm_file.m_path;

RunInTerminalDebugAdapterCommChannel comm_channel(comm_file.m_path);
RunInTerminalDebugAdapterCommChannel comm_channel(comm_file);

lldb::pid_t debugger_pid = LLDB_INVALID_PROCESS_ID;
#if !defined(_WIN32)
Expand All @@ -130,10 +131,13 @@ RunInTerminal(DAP &dap, const protocol::LaunchRequestArguments &arguments) {

llvm::json::Object reverse_request = CreateRunInTerminalReverseRequest(
*arguments.configuration.program, arguments.args, arguments.env,
arguments.cwd.value_or(""), comm_file.m_path, debugger_pid);
arguments.cwd.value_or(""), comm_filename, debugger_pid);
dap.SendReverseRequest<LogFailureResponseHandler>("runInTerminal",
std::move(reverse_request));

auto err = comm_channel.WaitForLauncher();
if (err)
return err;
if (llvm::Expected<lldb::pid_t> pid = comm_channel.GetLauncherPid())
attach_info.SetProcessID(*pid);
else
Expand Down
2 changes: 1 addition & 1 deletion lldb/tools/lldb-dap/JSONUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1463,7 +1463,7 @@ llvm::json::Object CreateRunInTerminalReverseRequest(
req_args.push_back("--launch-target");
req_args.push_back(program.str());
req_args.insert(req_args.end(), args.begin(), args.end());
run_in_terminal_args.try_emplace("args", args);
run_in_terminal_args.try_emplace("args", req_args);

if (!cwd.empty())
run_in_terminal_args.try_emplace("cwd", cwd);
Expand Down
Loading