Skip to content

Commit 0a751f9

Browse files
committed
cmd: add streaming command execution as $cmd/:session
Introduces a new stream-based command execution alongside the existing one-shot cmd resource. The streaming variant registers under the "$cmd/:session" path (using the $-prefix convention reserved for internal/system stream resources, like $terminal) and exposes a session-lifetime channel where each frame carries either stdout, stderr or the final exit code. cmd_stream subclasses stream_manager and creates a cmd_stream_session per stream. The session launches the requested shell command via boost::process v2 with separate stdout/stderr pipes and a writable stdin pipe. As output is produced it is sent as STREAM_DATA frames of the form {"out":<bin>}, {"err":<bin>} and a final {"exit":N} (with optional {"timeout":true}). Incoming STREAM_DATA frames carrying a binary or string payload are forwarded to the process stdin, enabling basic interactive use cases like sudo -S or commands that read from stdin. A coordinator coroutine waits for stdout and stderr to drain (via EOF or operation_aborted) and for the process to exit, so the final exit frame is always emitted after all output has been flushed. An optional timeout terminates the process if the command exceeds it. The standalone test client (main.cpp) now registers both cmd and cmd_stream so the new resource is reachable out of the box.
1 parent af310cd commit 0a751f9

5 files changed

Lines changed: 327 additions & 0 deletions

File tree

src/main.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
#include "thinger/iotmp/extensions/terminal/terminal.hpp"
1515
#include "thinger/iotmp/extensions/proxy/proxy.hpp"
1616
#include "thinger/iotmp/extensions/version/version.hpp"
17+
#include "thinger/iotmp/extensions/cmd/cmd.hpp"
18+
#include "thinger/iotmp/extensions/cmd/cmd_stream.hpp"
1719

1820
using namespace thinger::iotmp;
1921
namespace po = boost::program_options;
@@ -87,6 +89,8 @@ int main(int argc, char* argv[]) {
8789
filesystem fs(iotmp_client, fs_path.empty() ? std::filesystem::current_path() : std::filesystem::path(fs_path));
8890
proxy tcp_proxy(iotmp_client);
8991
version ver(iotmp_client);
92+
cmd cmd_extension(iotmp_client);
93+
cmd_stream cmd_stream_extension(iotmp_client);
9094

9195
// Iniciar cliente (arranca workers automáticamente)
9296
std::cout << "Starting async client...\n";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#include "cmd_stream.hpp"
2+
#include "cmd_stream_session.hpp"
3+
4+
namespace thinger::iotmp {
5+
6+
cmd_stream::cmd_stream(client& client)
7+
: stream_manager(client, "$cmd/:session")
8+
{
9+
}
10+
11+
std::shared_ptr<stream_session> cmd_stream::create_session(client& client, uint16_t stream_id,
12+
std::string session, json_t& parameters) {
13+
return std::make_shared<cmd_stream_session>(client, stream_id, std::move(session), parameters);
14+
}
15+
16+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#ifndef THINGER_IOTMP_CMD_STREAM_HPP
2+
#define THINGER_IOTMP_CMD_STREAM_HPP
3+
4+
#include "../../core/iotmp_stream_manager.hpp"
5+
#include <memory>
6+
#include <string>
7+
8+
namespace thinger::iotmp {
9+
10+
class cmd_stream : public stream_manager {
11+
12+
public:
13+
explicit cmd_stream(client& client);
14+
~cmd_stream() override = default;
15+
16+
protected:
17+
std::shared_ptr<stream_session> create_session(client& client, uint16_t stream_id,
18+
std::string session, json_t& parameters) override;
19+
};
20+
21+
}
22+
23+
#endif
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#include "cmd_stream_session.hpp"
2+
#include "../../util/shell.hpp"
3+
4+
#include <boost/asio.hpp>
5+
#include <boost/asio/experimental/awaitable_operators.hpp>
6+
#include <boost/process/v2/stdio.hpp>
7+
#include <thinger/util/logger.hpp>
8+
9+
#include <chrono>
10+
#include <utility>
11+
12+
namespace thinger::iotmp {
13+
14+
using namespace boost::asio::experimental::awaitable_operators;
15+
16+
cmd_stream_session::cmd_stream_session(client& client, uint16_t stream_id, std::string session,
17+
json_t& parameters)
18+
: stream_session(client, stream_id, std::move(session)),
19+
stdin_pipe_(client.get_io_context()),
20+
stdout_pipe_(client.get_io_context()),
21+
stderr_pipe_(client.get_io_context()),
22+
timeout_timer_(client.get_io_context())
23+
{
24+
ensure_home_env();
25+
command_ = get_value(parameters, "cmd", empty::string);
26+
timeout_seconds_ = get_value(parameters, "timeout", 0);
27+
THINGER_LOG("cmd stream {} created: '{}' (timeout: {}s)",
28+
stream_id_, command_, timeout_seconds_);
29+
}
30+
31+
cmd_stream_session::~cmd_stream_session() {
32+
// bp2::process destructor calls std::terminate() if ownership has not
33+
// been released. async_wait reaps the child but does not flip the
34+
// internal state to "released", so we must call detach() explicitly.
35+
if (process_) {
36+
process_->detach();
37+
}
38+
THINGER_LOG("cmd stream {} destroyed", stream_id_);
39+
}
40+
41+
awaitable<exec_result> cmd_stream_session::start() {
42+
if (command_.empty()) {
43+
co_return exec_result{false, "no command provided"};
44+
}
45+
46+
namespace bp2 = boost::process::v2;
47+
boost::system::error_code ec;
48+
std::vector<std::string> args = {"-c", command_};
49+
process_.emplace(
50+
client_.get_io_context().get_executor(),
51+
preferred_shell(),
52+
args,
53+
bp2::process_stdio{stdin_pipe_, stdout_pipe_, stderr_pipe_},
54+
ec
55+
);
56+
57+
if (ec) {
58+
THINGER_LOG_ERROR("cmd stream {} failed to launch: {}", stream_id_, ec.message());
59+
process_.reset();
60+
co_return exec_result{false, ec.message()};
61+
}
62+
63+
THINGER_LOG("cmd stream {} launched (pid {})", stream_id_, process_->id());
64+
65+
// Spawn the coordinator coroutine; it keeps the session alive via
66+
// shared_from_this() until stdout, stderr, and the process all complete.
67+
co_spawn(client_.get_io_context(), run(), detached);
68+
69+
if (timeout_seconds_ > 0) {
70+
co_spawn(client_.get_io_context(), watch_timeout(), detached);
71+
}
72+
73+
co_return true;
74+
}
75+
76+
bool cmd_stream_session::stop(StopReason reason) {
77+
stopped_externally_ = true;
78+
79+
if (process_ && !finished_) {
80+
boost::system::error_code ec;
81+
process_->terminate(ec);
82+
}
83+
84+
boost::system::error_code ec;
85+
stdin_pipe_.close(ec);
86+
stdout_pipe_.close(ec);
87+
stderr_pipe_.close(ec);
88+
timeout_timer_.cancel();
89+
90+
return true;
91+
}
92+
93+
void cmd_stream_session::handle_input(input& in) {
94+
if (finished_ || !stdin_pipe_.is_open()) return;
95+
96+
// Accept either raw binary payloads or string payloads. Anything else
97+
// (objects, arrays, numbers) is ignored — the convention is that
98+
// stdin data is transported verbatim.
99+
const auto& payload = in.payload();
100+
std::string data;
101+
if (payload.is_binary()) {
102+
const auto& bin = payload.get_binary();
103+
data.assign(bin.begin(), bin.end());
104+
} else if (payload.is_string()) {
105+
data = payload.get<std::string>();
106+
} else {
107+
return;
108+
}
109+
if (data.empty()) return;
110+
111+
increase_received(data.size());
112+
stdin_queue_.emplace(std::move(data));
113+
114+
if (!stdin_writing_) {
115+
stdin_writing_ = true;
116+
co_spawn(client_.get_io_context(), stdin_write_loop(), detached);
117+
}
118+
}
119+
120+
awaitable<void> cmd_stream_session::stdin_write_loop() {
121+
auto self = shared_from_this();
122+
while (!stdin_queue_.empty() && stdin_pipe_.is_open()) {
123+
auto data = std::move(stdin_queue_.front());
124+
stdin_queue_.pop();
125+
auto [ec, bytes] = co_await boost::asio::async_write(
126+
stdin_pipe_,
127+
boost::asio::buffer(data),
128+
use_nothrow_awaitable);
129+
if (ec) {
130+
if (ec != boost::asio::error::operation_aborted) {
131+
LOG_WARNING("cmd stream {} stdin write failed: {}",
132+
stream_id_, ec.message());
133+
}
134+
break;
135+
}
136+
}
137+
stdin_writing_ = false;
138+
}
139+
140+
awaitable<void> cmd_stream_session::run() {
141+
auto self = shared_from_this();
142+
143+
// Wait until stdout, stderr, and the process have all completed.
144+
// Reads finish on EOF (process closes its end) or operation_aborted
145+
// (stop() closes the pipes); the process completes via async_wait.
146+
co_await (read_stdout() && read_stderr() && wait_process());
147+
148+
timeout_timer_.cancel();
149+
150+
// Send the final exit frame and notify the server that the stream is
151+
// over, unless the server already requested the stop itself.
152+
json_t frame;
153+
frame["exit"] = exit_code_;
154+
if (timed_out_) frame["timeout"] = true;
155+
client_.stream_resource(stream_id_, std::move(frame));
156+
157+
if (process_) {
158+
process_->detach();
159+
}
160+
// Note: we do NOT call client_.stop_stream() here. The session ending
161+
// triggers the stream_manager's on_end_ listener which already sends
162+
// STOP_STREAM via client_.stop_stream() when the session is gone.
163+
// Sending it from both paths produces a duplicate STOP_STREAM.
164+
}
165+
166+
awaitable<void> cmd_stream_session::read_stdout() {
167+
auto self = shared_from_this();
168+
while (true) {
169+
auto [ec, bytes] = co_await stdout_pipe_.async_read_some(
170+
boost::asio::buffer(stdout_buffer_, CMD_STREAM_BUFFER_SIZE),
171+
use_nothrow_awaitable);
172+
if (ec) break;
173+
if (bytes > 0) {
174+
increase_sent(bytes);
175+
json_t frame;
176+
frame["out"] = json_t::binary({stdout_buffer_, stdout_buffer_ + bytes});
177+
client_.stream_resource(stream_id_, std::move(frame));
178+
}
179+
}
180+
}
181+
182+
awaitable<void> cmd_stream_session::read_stderr() {
183+
auto self = shared_from_this();
184+
while (true) {
185+
auto [ec, bytes] = co_await stderr_pipe_.async_read_some(
186+
boost::asio::buffer(stderr_buffer_, CMD_STREAM_BUFFER_SIZE),
187+
use_nothrow_awaitable);
188+
if (ec) break;
189+
if (bytes > 0) {
190+
increase_sent(bytes);
191+
json_t frame;
192+
frame["err"] = json_t::binary({stderr_buffer_, stderr_buffer_ + bytes});
193+
client_.stream_resource(stream_id_, std::move(frame));
194+
}
195+
}
196+
}
197+
198+
awaitable<void> cmd_stream_session::wait_process() {
199+
auto self = shared_from_this();
200+
if (!process_) co_return;
201+
202+
namespace bp2 = boost::process::v2;
203+
auto [ec, native_code] = co_await process_->async_wait(use_nothrow_awaitable);
204+
exit_code_ = ec ? -1 : bp2::evaluate_exit_code(native_code);
205+
finished_ = true;
206+
}
207+
208+
awaitable<void> cmd_stream_session::watch_timeout() {
209+
auto self = shared_from_this();
210+
timeout_timer_.expires_after(std::chrono::seconds(timeout_seconds_));
211+
auto [ec] = co_await timeout_timer_.async_wait(use_nothrow_awaitable);
212+
if (ec) co_return; // cancelled
213+
if (process_ && !finished_) {
214+
LOG_WARNING("cmd stream {} timed out after {}s, terminating",
215+
stream_id_, timeout_seconds_);
216+
timed_out_ = true;
217+
boost::system::error_code term_ec;
218+
process_->terminate(term_ec);
219+
}
220+
}
221+
222+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#ifndef THINGER_IOTMP_CMD_STREAM_SESSION_HPP
2+
#define THINGER_IOTMP_CMD_STREAM_SESSION_HPP
3+
4+
#include "../../client.hpp"
5+
#include "../../core/iotmp_stream_session.hpp"
6+
7+
#include <boost/asio/readable_pipe.hpp>
8+
#include <boost/asio/writable_pipe.hpp>
9+
#include <boost/asio/steady_timer.hpp>
10+
#include <boost/process/v2/process.hpp>
11+
12+
#include <cstdint>
13+
#include <memory>
14+
#include <optional>
15+
#include <queue>
16+
#include <string>
17+
18+
namespace thinger::iotmp {
19+
20+
constexpr size_t CMD_STREAM_BUFFER_SIZE = 4096;
21+
22+
class cmd_stream_session : public stream_session {
23+
24+
public:
25+
cmd_stream_session(client& client, uint16_t stream_id, std::string session, json_t& parameters);
26+
~cmd_stream_session() override;
27+
28+
awaitable<exec_result> start() override;
29+
bool stop(StopReason reason = StopReason::SERVER_STOP) override;
30+
void handle_input(input& in) override;
31+
32+
private:
33+
awaitable<void> run();
34+
awaitable<void> read_stdout();
35+
awaitable<void> read_stderr();
36+
awaitable<void> wait_process();
37+
awaitable<void> watch_timeout();
38+
awaitable<void> stdin_write_loop();
39+
40+
std::string command_;
41+
int timeout_seconds_ = 0;
42+
bool timed_out_ = false;
43+
bool finished_ = false;
44+
bool stopped_externally_ = false;
45+
int exit_code_ = -1;
46+
47+
boost::asio::writable_pipe stdin_pipe_;
48+
boost::asio::readable_pipe stdout_pipe_;
49+
boost::asio::readable_pipe stderr_pipe_;
50+
boost::asio::steady_timer timeout_timer_;
51+
std::optional<boost::process::v2::process> process_;
52+
53+
uint8_t stdout_buffer_[CMD_STREAM_BUFFER_SIZE];
54+
uint8_t stderr_buffer_[CMD_STREAM_BUFFER_SIZE];
55+
56+
std::queue<std::string> stdin_queue_;
57+
bool stdin_writing_ = false;
58+
};
59+
60+
}
61+
62+
#endif

0 commit comments

Comments
 (0)