Skip to content

Commit 770b9cc

Browse files
authored
Merge pull request #444 from foundriesio/detsch-popen-with-stderr
exec: Create wrapper to capture stderr output separately from stdout
2 parents 6cbc10a + 274a18e commit 770b9cc

File tree

2 files changed

+224
-41
lines changed

2 files changed

+224
-41
lines changed

src/exec.cc

Lines changed: 211 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,211 @@
11
#include "exec.h"
22

3+
#include <boost/chrono.hpp>
34
#include <boost/filesystem.hpp>
45
#include <boost/format.hpp>
56

67
#include "logging/logging.h"
78

9+
#include <fcntl.h>
10+
#include <spawn.h>
11+
#include <sys/wait.h>
12+
#include <unistd.h>
13+
#include <array>
14+
#include <cstring>
15+
#include <iostream>
16+
#include <string>
17+
#include <vector>
18+
19+
struct ProcessResult {
20+
std::string stdout_output;
21+
std::string stderr_output;
22+
int exit_code;
23+
};
24+
25+
static long long convertToSeconds(std::string input);
26+
class Process {
27+
private:
28+
int stdout_pipe[2];
29+
int stderr_pipe[2];
30+
pid_t pid;
31+
32+
// Set file descriptor to non-blocking mode
33+
bool setNonBlocking(int fd) {
34+
int flags = fcntl(fd, F_GETFL, 0);
35+
if (flags == -1) return false;
36+
return fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
37+
}
38+
39+
// Read from both pipes simultaneously to avoid deadlock
40+
void readFromBothPipes(std::string& stdout_data, std::string& stderr_data, bool print_output,
41+
const std::string& timeout) {
42+
std::array<char, 4096> buffer;
43+
fd_set read_fds;
44+
int max_fd = std::max(stdout_pipe[0], stderr_pipe[0]) + 1;
45+
bool stdout_open = true;
46+
bool stderr_open = true;
47+
48+
// Set both pipes to non-blocking mode
49+
setNonBlocking(stdout_pipe[0]);
50+
setNonBlocking(stderr_pipe[0]);
51+
52+
auto start_time = boost::chrono::steady_clock::now();
53+
auto end_time = start_time;
54+
if (!timeout.empty()) {
55+
end_time = start_time + boost::chrono::seconds(convertToSeconds(timeout));
56+
}
57+
while (stdout_open || stderr_open) {
58+
FD_ZERO(&read_fds);
59+
60+
if (stdout_open) FD_SET(stdout_pipe[0], &read_fds);
61+
if (stderr_open) FD_SET(stderr_pipe[0], &read_fds);
62+
63+
// Wait for data on either pipe (with timeout)
64+
struct timeval select_timeout;
65+
select_timeout.tv_sec = 1;
66+
select_timeout.tv_usec = 0;
67+
68+
int result = select(max_fd, &read_fds, nullptr, nullptr, &select_timeout);
69+
if (result == -1) {
70+
LOG_ERROR << "Error in select: " << strerror(errno);
71+
throw std::runtime_error("exec: Error creating pipes");
72+
}
73+
74+
// Read from stdout if data is available
75+
if (stdout_open && FD_ISSET(stdout_pipe[0], &read_fds)) {
76+
ssize_t bytes_read = read(stdout_pipe[0], buffer.data(), buffer.size());
77+
if (bytes_read > 0) {
78+
stdout_data.append(buffer.data(), bytes_read);
79+
if (print_output) {
80+
std::cout.write(buffer.data(), bytes_read);
81+
std::cout.flush();
82+
}
83+
} else if (bytes_read == 0) {
84+
stdout_open = false;
85+
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
86+
stdout_open = false;
87+
}
88+
}
89+
90+
// Read from stderr if data is available
91+
if (stderr_open && FD_ISSET(stderr_pipe[0], &read_fds)) {
92+
ssize_t bytes_read = read(stderr_pipe[0], buffer.data(), buffer.size());
93+
if (bytes_read > 0) {
94+
stderr_data.append(buffer.data(), bytes_read);
95+
} else if (bytes_read == 0) {
96+
stderr_open = false;
97+
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
98+
stderr_open = false;
99+
}
100+
}
101+
if (!timeout.empty() && boost::chrono::steady_clock::now() > end_time) {
102+
throw std::runtime_error("Timeout occurred while waiting for a child process completion");
103+
}
104+
}
105+
}
106+
107+
public:
108+
Process() : pid(-1) {
109+
stdout_pipe[0] = stdout_pipe[1] = -1;
110+
stderr_pipe[0] = stderr_pipe[1] = -1;
111+
}
112+
113+
~Process() { closeAllPipes(); }
114+
115+
void closeAllPipes() {
116+
if (stdout_pipe[0] != -1) close(stdout_pipe[0]);
117+
if (stdout_pipe[1] != -1) close(stdout_pipe[1]);
118+
if (stderr_pipe[0] != -1) close(stderr_pipe[0]);
119+
if (stderr_pipe[1] != -1) close(stderr_pipe[1]);
120+
121+
stdout_pipe[0] = stdout_pipe[1] = -1;
122+
stderr_pipe[0] = stderr_pipe[1] = -1;
123+
}
124+
125+
ProcessResult execute(const std::string& command, bool print_output, const std::string& timeout) {
126+
ProcessResult result;
127+
result.exit_code = -1;
128+
129+
// Create pipes for stdout and stderr
130+
if (pipe(stdout_pipe) == -1 || pipe(stderr_pipe) == -1) {
131+
LOG_ERROR << "Error creating pipes: " << strerror(errno);
132+
throw std::runtime_error("exec: Error creating pipes");
133+
}
134+
135+
// Setup file actions for posix_spawn
136+
posix_spawn_file_actions_t file_actions;
137+
posix_spawn_file_actions_init(&file_actions);
138+
139+
// Redirect stdout to pipe
140+
posix_spawn_file_actions_addclose(&file_actions, stdout_pipe[0]);
141+
posix_spawn_file_actions_adddup2(&file_actions, stdout_pipe[1], STDOUT_FILENO);
142+
posix_spawn_file_actions_addclose(&file_actions, stdout_pipe[1]);
143+
144+
// Redirect stderr to pipe
145+
posix_spawn_file_actions_addclose(&file_actions, stderr_pipe[0]);
146+
posix_spawn_file_actions_adddup2(&file_actions, stderr_pipe[1], STDERR_FILENO);
147+
posix_spawn_file_actions_addclose(&file_actions, stderr_pipe[1]);
148+
149+
// Prepare arguments for shell execution
150+
char* argv[] = {const_cast<char*>("sh"), const_cast<char*>("-c"), const_cast<char*>(command.c_str()), nullptr};
151+
152+
// Spawn the process
153+
int spawn_result = posix_spawn(&pid, "/bin/sh", &file_actions, nullptr, argv, environ);
154+
155+
// Clean up file actions
156+
posix_spawn_file_actions_destroy(&file_actions);
157+
158+
if (spawn_result != 0) {
159+
LOG_ERROR << "Error spawning process: " << strerror(spawn_result);
160+
closeAllPipes();
161+
throw std::runtime_error("exec: Error spawning process");
162+
}
163+
164+
// Parent process - close write ends of pipes
165+
close(stdout_pipe[1]);
166+
stdout_pipe[1] = -1;
167+
close(stderr_pipe[1]);
168+
stderr_pipe[1] = -1;
169+
170+
// Read from both pipes simultaneously to avoid deadlock
171+
readFromBothPipes(result.stdout_output, result.stderr_output, print_output, timeout);
172+
173+
// Close read ends
174+
close(stdout_pipe[0]);
175+
stdout_pipe[0] = -1;
176+
close(stderr_pipe[0]);
177+
stderr_pipe[0] = -1;
178+
179+
// Wait for child process to finish
180+
int status;
181+
waitpid(pid, &status, 0);
182+
183+
if (WIFEXITED(status)) {
184+
result.exit_code = WEXITSTATUS(status);
185+
}
186+
187+
return result;
188+
}
189+
};
190+
191+
static long long convertToSeconds(std::string input) {
192+
if (input.empty()) return 0;
193+
194+
char unit = input.back();
195+
long long value = std::stoll(input.substr(0, input.size() - 1));
196+
197+
switch (unit) {
198+
case 'h':
199+
return value * 3600;
200+
case 'm':
201+
return value * 60;
202+
case 's':
203+
return value;
204+
default:
205+
throw std::invalid_argument("Invalid time interval " + input);
206+
}
207+
}
208+
8209
void exec(const std::string& cmd, const std::string& err_msg_prefix, const boost::filesystem::path& start_dir,
9210
std::string* output, const std::string& timeout, bool print_output) {
10211
std::string command;
@@ -17,53 +218,25 @@ void exec(const std::string& cmd, const std::string& err_msg_prefix, const boost
17218
command = "PARENT_HAS_TTY=1 ";
18219
}
19220

20-
if (!timeout.empty()) {
21-
command += "timeout " + timeout + " ";
22-
}
23-
command += cmd + " 2>&1";
221+
command += cmd;
24222
if (!start_dir.empty()) {
25223
command = "cd " + start_dir.string() + " && " + command;
26224
}
27225

28226
LOG_DEBUG << "Running: `" << command << "`";
29-
FILE* pipe = popen(command.c_str(), "r");
30-
if (!pipe) {
31-
throw std::runtime_error("exec: popen() failed!");
32-
}
33-
std::string result;
34-
try {
35-
std::array<char, 128> buffer_array = {};
36-
char* buffer = buffer_array.data();
37-
while (std::fgets(buffer, sizeof(buffer_array), pipe) != nullptr) {
38-
if (print_output) {
39-
fputs(buffer, stdout);
40-
}
41-
result += buffer;
42-
}
227+
Process proc;
228+
auto result = proc.execute(command.c_str(), print_output, timeout);
43229

44-
if (output != nullptr) {
45-
*output = result;
46-
}
47-
} catch (const std::exception& e) {
48-
pclose(pipe);
49-
throw std::runtime_error(std::string("exec: ") + e.what());
50-
}
230+
LOG_DEBUG << "Command exited with code " << result.exit_code;
51231

52-
int status = pclose(pipe);
53-
if (status == -1) {
54-
throw std::runtime_error("exec: pclose() failed!");
232+
if (result.exit_code != EXIT_SUCCESS) {
233+
throw ExecError(err_msg_prefix, cmd, result.stderr_output, result.exit_code);
55234
}
56-
57-
int exit_code = WEXITSTATUS(status);
58-
59-
if (exit_code == 124) {
60-
// `timeout` command return code indicating that a timeout has occured
61-
throw std::runtime_error("Timeout occurred while waiting for a child process completion");
235+
if (output != nullptr) {
236+
*output = result.stdout_output;
62237
}
63-
LOG_DEBUG << "Command exited with code " << exit_code;
64-
65-
if (exit_code != EXIT_SUCCESS) {
66-
throw ExecError(err_msg_prefix, cmd, result, exit_code);
238+
if (result.stderr_output.size() > 0) {
239+
LOG_DEBUG << "Command stderr: " << result.stderr_output;
67240
}
68241
}
69242

tests/exec_test.cc

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@ TEST(Exec, FailedExec) {
1717
} catch (const std::exception& exc) {
1818
const std::string err_msg{exc.what()};
1919

20-
ASSERT_NE(err_msg.find("failed to run command"), std::string::npos);
20+
ASSERT_NE(err_msg.find("not found"), std::string::npos) << "Actual error message: " + err_msg;
2121
ASSERT_NE(err_msg.find(executable), std::string::npos) << "Actual error message: " + err_msg;
2222
;
23-
ASSERT_NE(err_msg.find("No such file or directory"), std::string::npos) << "Actual error message: " + err_msg;
24-
;
2523
}
2624
}
2725

@@ -41,6 +39,18 @@ TEST(Exec, SuccessfulExecFailedExecutable) {
4139
}
4240
}
4341

42+
TEST(Exec, ExecTimeout) {
43+
const std::string cmd{"sleep 10"};
44+
45+
try {
46+
exec(cmd, "", "", nullptr, "2s", false);
47+
} catch (const std::runtime_error& exc) {
48+
const std::string err_msg{exc.what()};
49+
50+
ASSERT_NE(err_msg.find("Timeout"), std::string::npos) << "Actual error message: " + err_msg;
51+
}
52+
}
53+
4454
int main(int argc, char** argv) {
4555
::testing::InitGoogleTest(&argc, argv);
4656
return RUN_ALL_TESTS();

0 commit comments

Comments
 (0)