From b207c288777441d5755b85fde17b96cf72e74590 Mon Sep 17 00:00:00 2001 From: Antruly Date: Sun, 25 Oct 2026 00:34:38 +0800 Subject: [PATCH] feat(listener): isolate TCP/UDP listeners in child processes - add isolated config to listeners, persisted and exposed in the API and editor - add --listener-child: reuse TransportManager for one listener over IPC with snapshot/events/sessions telemetry - add parent ListenerChildService with spawn, request/response commands for send/close/session-events, pid, stop and reap - branch TransportManager listener lifecycle/data methods on isolated - smoke covers client accept, API reply, session events, kill/error, restart with a new pid and delete reaping --- CHANGELOG.md | 10 + CMakeLists.txt | 1 + README.md | 1 + ROADMAP.md | 1 + docs/API.md | 5 + docs/ARCHITECTURE.md | 14 +- docs/DEVELOPMENT_PLAN.zh-CN.md | 30 ++ scripts/smoke-test.sh | 97 +++++ src/application.cpp | 3 +- src/listener_child.cpp | 674 ++++++++++++++++++++++++++++++++ src/listener_child.h | 50 +++ src/main.cpp | 7 + src/store.cpp | 3 + src/store.h | 1 + src/transport_manager.cpp | 132 ++++++- src/transport_manager.h | 2 +- web/src/i18n/listeners.ts | 2 + web/src/views/ListenersView.vue | 5 + 18 files changed, 1022 insertions(+), 16 deletions(-) create mode 100644 src/listener_child.cpp create mode 100644 src/listener_child.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afeb28..80e3a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to uvscope are documented here. The format is based on ### Added +- TCP and UDP listeners can run in a child process with `isolated: true`. The parent caches the + listener snapshot, event log and session list, exposes the child `pid`, proxies session send / + close / per-session event reads over IPC, and stops/reaps the child on stop or delete. A + crashed child is reported as `error` and can be started again with a new PID. The listener + editor has a “Run in a child process” toggle. The smoke test accepts a client through the + isolated listener, replies through the daemon API, checks session events, kills/restarts the + child and verifies reaping on delete. + +### Added + - SOCKS5 servers can run in a child process with `isolated: true`. The parent caches the server snapshot, event log and session list, exposes the child `pid` in `/api/socks5/servers`, and stops/reaps the child on stop or delete. A crashed child is reported as `error` and can be diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b7b29d..5825959 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -154,6 +154,7 @@ set(UVSCOPE_SOURCES src/download_manager.cpp src/http_probe.cpp src/ipc.cpp + src/listener_child.cpp src/net_probe.cpp src/preview_store.cpp src/quic_manager.cpp diff --git a/README.md b/README.md index 073e08a..25cb283 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Most tools stop at request/response. uvscope is designed around the whole handsh | Length-prefixed JSON IPC and child-process runtime foundation | ✅ New in 0.10.0 | | Optional per-proxy child-process isolation with supervisor restart | ✅ New in 0.10.0 | | Optional SOCKS5 server child-process isolation | ✅ New | +| Optional TCP/UDP listener child-process isolation | ✅ New | | Weighted upstreams with failover and retries | ✅ New in 0.4.4 | | Per-proxy QPS, p50/p95 latency and status counts | ✅ New in 0.4.5 | | Active upstream health checks (TCP/TLS, 10 s) | ✅ New in 0.4.5 | diff --git a/ROADMAP.md b/ROADMAP.md index eaf7af4..caf5552 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,6 +79,7 @@ uvscope is developed in small, verifiable slices. The detailed plan is in - [x] Reverse-proxy child runtime with IPC start/stop/snapshot/logs - [x] Per-proxy child isolation with supervisor restart and telemetry - [x] SOCKS5 server child isolation with telemetry and manual restart +- [x] TCP/UDP listener child isolation with session send/close over IPC - [ ] Process isolation + IPC for long-lived services ## 1.0 — stable diff --git a/docs/API.md b/docs/API.md index 951bfc4..1dcb8ba 100644 --- a/docs/API.md +++ b/docs/API.md @@ -332,6 +332,11 @@ produce accepted-session records with `peer`, `state`, byte counters and a per-s UDP listeners produce `datagram` and `sent` events with `peer` and `payload`; replies use the numeric peer host/port from the datagram log. +`isolated: true` runs the listener in a `--listener-child` process. Its snapshot includes +`isolated` and `pid`, and session send / close / event reads are proxied over IPC. If the child +exits, the state becomes `error`; starting it again spawns a new child. Deleting the listener +stops and reaps the child. + Listener states are `stopped`, `starting`, `listening`, `stopping` and `error`. A listener error keeps the config; fix the port and start it again. Listener configs are stored in SQLite settings and auto-started when `autoStart` is true. Limits mirror the TCP session limits; accepted sessions diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e48e262..9981a91 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,10 @@ persistence, protocol clients and the gateway runtimes. The SPA is a view over t - **TCP/UDP listeners**: the same transport loop hosts `uvcpp_tcp_server` listeners and raw `uv_udp_t` sockets. TCP accepted clients stay server-owned; the manager only observes them and queues writes. Listener configs live in SQLite; runtime state and bounded event logs live in the - manager. + manager. A listener marked `isolated: true` runs in a `--listener-child` process; the parent + `ListenerChildService` caches snapshots/events/sessions, exposes the PID, and proxies session + send / close / event reads over IPC. A crashed child is reported as `error` and can be started + again, but there is no listener supervisor yet. - **SOCKS5 servers**: each running server is a `uvcpp_socks5_server` with its own loop and thread. Commands are marshaled through `uvcpp_async`; snapshots and bounded logs are mutex-protected. A server marked `isolated: true` instead runs in a `--socks5-child` process; the parent @@ -312,8 +315,7 @@ not an HTTP 500 for the API; it is a successful probe request whose `ok` is fals weighted upstreams with bounded retries, active health checks and a per-upstream circuit breaker. - The `history` table stores summaries, not full bodies. -- Reverse proxies can run in supervised child processes (`isolated: true`), and SOCKS5 servers - can run in child processes without a supervisor. TCP/UDP listeners and the virtual terminal - still run in the daemon process; moving those services into children is the remaining - process-isolation work. Isolated telemetry is cached from a 500 ms push, so the API can lag the - child slightly. +- Reverse proxies can run in supervised child processes (`isolated: true`); SOCKS5 servers and + TCP/UDP listeners can run in child processes without a supervisor. The virtual terminal still + runs in the daemon process; moving it into children is the remaining process-isolation work. + Isolated telemetry is cached from a 500 ms push, so the API can lag the child slightly. diff --git a/docs/DEVELOPMENT_PLAN.zh-CN.md b/docs/DEVELOPMENT_PLAN.zh-CN.md index 99b5712..cdbe93e 100644 --- a/docs/DEVELOPMENT_PLAN.zh-CN.md +++ b/docs/DEVELOPMENT_PLAN.zh-CN.md @@ -715,6 +715,36 @@ smoke 创建隔离 SOCKS5,经其 CONNECT 到本地 TCP echo;kill 子进程 不做:SOCKS5 supervisor 自动重启、TCP/UDP 监听与终端子进程化、跨进程鉴权、资源限制。 +### S17 — TCP/UDP 监听子进程隔离(已实现) + +边界:`src/listener_child.*`、`src/transport_manager.*`、`src/main.cpp`、API/编辑器、smoke。 + +内容: + +- `ListenerConfig.isolated` 持久化到 settings,创建表单增加“在子进程中运行”; +- 隐藏 `--listener-child`:子进程复用 `TransportManager` 跑单个 TCP/UDP 监听,通过 IPC 接收 + start/stop/send/close-session/session-events,推送 listener/events/sessions 遥测; +- 父侧 `ListenerChildService`:独立 IPC 循环线程、spawn、500 ms 遥测、会话/事件缓存、PID、 + 请求/响应命令与停止/reap; +- `TransportManager` 在 start/stop/remove/listeners/listener/events/sessions/session-events/ + send/close-session 上按 `isolated` 分支; +- 子进程异常退出后状态为 `error`,再次 start 生成新 PID(暂无 supervisor 自动重启); +- 删除时停止并回收子进程。 + +验收: + +```sh +./scripts/build.sh +./build/uvscope --self-check +bash ./scripts/smoke-test.sh ./build/uvscope +``` + +smoke 创建隔离 TCP listener,客户端接入并通过 API 回包,检查 session 的 recv/send 事件; +kill 子进程后状态 error,start 后新 PID;删除后确认子进程消失。 + +不做:listener supervisor 自动重启、虚拟终端子进程化、跨进程鉴权、资源限制、UDP 隔离的 +额外专项用例(与 TCP 共用同一子进程实现)。 + --- ### 风险与回退(本迭代) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a4f265e..7c9f6d9 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -25,6 +25,7 @@ HOT_PROXY_PORT="${20:-18805}" BREAKER_PROXY_PORT="${21:-18806}" ISOLATED_PROXY_PORT="${22:-18807}" ISOLATED_SOCKS5_PORT="${23:-18808}" +ISOLATED_LISTENER_PORT="${24:-18809}" DATA_DIR="$(mktemp -d "${TMPDIR:-/tmp}/uvscope-smoke.XXXXXX")" COOKIE_JAR="${DATA_DIR}/cookies.txt" LOG="${DATA_DIR}/daemon.log" @@ -596,6 +597,7 @@ WS_ROUTE_PORT="${WS_ROUTE_PORT}" ASYNC_PROXY_PORT="${ASYNC_PROXY_PORT}" \ ERROR_PROXY_PORT="${ERROR_PROXY_PORT}" TIMEOUT_PROXY_PORT="${TIMEOUT_PROXY_PORT}" \ HOT_PROXY_PORT="${HOT_PROXY_PORT}" BREAKER_PROXY_PORT="${BREAKER_PROXY_PORT}" \ ISOLATED_PROXY_PORT="${ISOLATED_PROXY_PORT}" ISOLATED_SOCKS5_PORT="${ISOLATED_SOCKS5_PORT}" \ +ISOLATED_LISTENER_PORT="${ISOLATED_LISTENER_PORT}" \ COMP_PORT="${COMP_PORT}" COMP_READY="${COMP_READY}" WS_PORT="${WS_PORT}" \ TCP_LISTEN_PORT="${TCP_LISTEN_PORT}" UDP_LISTEN_PORT="${UDP_LISTEN_PORT}" \ SOCKS5_PORT="${SOCKS5_PORT}" SOCKS5_AUTH_PORT="${SOCKS5_AUTH_PORT}" \ @@ -1152,6 +1154,101 @@ if isolated_socks: check(status == 200 and deleted.get("ok") is True, f"isolated SOCKS5 delete failed: {deleted}") +# Isolated TCP listener: accept a client, reply through the daemon API, read the +# session events, kill/restart the child and reap it on delete. +status, created = call("POST", "/api/transport/listeners", { + "transport": "tcp", "name": "smoke-isolated-listener", + "host": "127.0.0.1", "port": int(os.environ["ISOLATED_LISTENER_PORT"]), + "isolated": True, "start": True, +}) +isolated_listener = created.get("listener", "") +check(status == 200 and bool(isolated_listener), + f"isolated listener create failed: {created}") +if isolated_listener: + child_pid = 0 + state = {} + for _ in range(100): + status, payload = call("GET", f"/api/transport/listeners/{isolated_listener}") + state = payload.get("listener", {}) + child_pid = state.get("pid", 0) + if state.get("state") == "listening" and child_pid: + break + time.sleep(0.1) + check(state.get("state") == "listening" and child_pid > 0 and + state.get("isolated") is True, + f"isolated listener did not listen: {state}") + client = socket.create_connection( + ("127.0.0.1", int(os.environ["ISOLATED_LISTENER_PORT"])), timeout=5) + client.sendall(b"iso-listener") + session_id = "" + for _ in range(100): + status, payload = call( + "GET", f"/api/transport/listeners/{isolated_listener}/sessions") + sessions = payload.get("sessions", []) + if sessions: + session_id = sessions[0].get("id", "") + break + time.sleep(0.1) + check(bool(session_id), "isolated listener did not record a session") + if session_id: + status, sent = call( + "POST", f"/api/transport/listeners/{isolated_listener}/send", + {"sessionId": session_id, "payload": "listener-reply", "encoding": "text"}) + check(status == 200 and sent.get("ok") is True, + f"isolated listener send failed: {sent}") + client.settimeout(5) + reply = client.recv(4096) + check(reply == b"listener-reply", + f"isolated listener reply mismatch: {reply}") + status, events = call( + "GET", + f"/api/transport/listeners/{isolated_listener}/sessions/{session_id}/events?after=0") + types = [item.get("type") for item in events.get("events", [])] + check("recv" in types and "send" in types, + f"isolated listener session events missing: {types}") + client.close() + new_pid = 0 + if os.name == "posix" and child_pid: + os.kill(child_pid, signal.SIGKILL) + for _ in range(80): + time.sleep(0.1) + status, payload = call("GET", f"/api/transport/listeners/{isolated_listener}") + state = payload.get("listener", {}) + if state.get("state") != "listening": + break + check(state.get("state") in ("error", "stopped"), + f"killed listener child still reports {state.get('state')}") + status, started = call( + "POST", f"/api/transport/listeners/{isolated_listener}/start") + check(status == 200 and started.get("ok") is True, + f"isolated listener restart failed: {started}") + for _ in range(100): + status, payload = call("GET", f"/api/transport/listeners/{isolated_listener}") + state = payload.get("listener", {}) + new_pid = state.get("pid", 0) + if state.get("state") == "listening" and new_pid and new_pid != child_pid: + break + time.sleep(0.1) + check(new_pid and new_pid != child_pid, + f"isolated listener did not restart: old={child_pid} new={new_pid}") + status, deleted = call( + "DELETE", f"/api/transport/listeners/{isolated_listener}") + check(status == 200 and deleted.get("ok") is True, + f"isolated listener delete failed: {deleted}") + if os.name == "posix": + target_pid = new_pid if new_pid else child_pid + reaped = False + for _ in range(80): + try: + os.kill(target_pid, 0) + except ProcessLookupError: + reaped = True + break + except PermissionError: + pass + time.sleep(0.1) + check(reaped, f"isolated listener child was not reaped: {target_pid}") + # Managed certificates: generate, inspect, import the same pair, delete. status, generated = call("POST", "/api/tls/certificates/generate", { "name": "smoke-cert", "commonName": "localhost", diff --git a/src/application.cpp b/src/application.cpp index 9b9b20e..36734b1 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -621,7 +621,7 @@ bool Application::initialize() { proxies_.reset(new ProxyManager(store_, options_.self_path)); supervisor_.reset(new Supervisor( [this](std::function fn) { app_.post(std::move(fn)); })); - transport_.reset(new TransportManager()); + transport_.reset(new TransportManager(options_.self_path)); if (!transport_->available()) { std::fprintf(stderr, "uvscope: transport sessions unavailable: %s\n", transport_->start_error().c_str()); @@ -1539,6 +1539,7 @@ void Application::register_debug_routes() { config.host = string_field(*body, "host", "127.0.0.1"); config.port = int_field(*body, "port", 0); config.auto_start = bool_field(*body, "autoStart", false); + config.isolated = bool_field(*body, "isolated", false); config.created_at = now_iso8601(); if ((config.transport != "tcp" && config.transport != "udp") || config.host.empty() || config.port <= 0 || config.port > 65535) { diff --git a/src/listener_child.cpp b/src/listener_child.cpp new file mode 100644 index 0000000..052d593 --- /dev/null +++ b/src/listener_child.cpp @@ -0,0 +1,674 @@ +#include "listener_child.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ipc.h" +#include "transport_manager.h" +#include "util.h" + +namespace uvscope { + +nlohmann::json listener_config_to_json(const ListenerConfig& config) { + return {{"id", config.id}, + {"transport", config.transport}, + {"name", config.name}, + {"host", config.host}, + {"port", config.port}, + {"autoStart", config.auto_start}, + {"isolated", config.isolated}}; +} + +bool listener_config_from_json(const nlohmann::json& value, ListenerConfig* config, + std::string* error) { + if (!value.is_object() || config == nullptr) { + if (error) *error = "listener config must be a JSON object"; + return false; + } + ListenerConfig parsed; + parsed.id = value.value("id", std::string()); + parsed.transport = value.value("transport", std::string("tcp")); + parsed.name = value.value("name", std::string()); + parsed.host = value.value("host", std::string("127.0.0.1")); + parsed.port = value.value("port", 0); + parsed.auto_start = value.value("autoStart", false); + parsed.isolated = false; + if (parsed.id.empty() || + (parsed.transport != "tcp" && parsed.transport != "udp") || + parsed.host.empty() || parsed.port <= 0 || parsed.port > 65535) { + if (error) *error = "invalid listener config"; + return false; + } + *config = std::move(parsed); + return true; +} + +int run_listener_child(const std::string& endpoint) { + uv_loop_t loop; + if (uv_loop_init(&loop) != 0) return 1; + TransportManager manager; + IpcChannel channel(&loop); + std::string listener_id; + struct Telemetry { + TransportManager* manager = nullptr; + IpcChannel* channel = nullptr; + std::string id; + bool started = false; + std::uint64_t last_event_seq = 0; + } telemetry; + telemetry.manager = &manager; + telemetry.channel = &channel; + + channel.set_on_ready([&channel] { + std::string error; + channel.send({{"type", "ready"}}, &error); + }); + channel.set_on_message( + [&channel, &manager, &listener_id, &telemetry](const nlohmann::json& message) { + if (!message.is_object()) return; + const std::string type = message.value("type", std::string()); + auto reply = [&channel](const nlohmann::json& value) { + std::string error; + channel.send(value, &error); + }; + if (type == "start") { + ListenerConfig config; + std::string error; + if (!listener_config_from_json( + message.value("config", nlohmann::json::object()), &config, &error)) { + reply({{"type", "error"}, {"error", error}}); + return; + } + listener_id = config.id; + if (!manager.add_listener(config, &error) || + !manager.start_listener(listener_id, &error)) { + manager.remove_listener(listener_id, nullptr); + reply({{"type", "error"}, {"error", error}}); + return; + } + telemetry.id = listener_id; + telemetry.started = true; + telemetry.last_event_seq = 0; + const auto current = manager.listener(listener_id); + reply({{"type", "started"}, + {"listener", current.value("listener", nlohmann::json::object())}}); + } else if (type == "snapshot") { + const auto current = manager.listener(listener_id); + reply({{"type", "snapshot"}, + {"listener", current.value("listener", nlohmann::json::object())}}); + } else if (type == "session-events") { + const std::string session_id = message.value("sessionId", std::string()); + const std::uint64_t after = message.value("after", 0); + const int limit = std::max(1, std::min(message.value("limit", 200), 500)); + const auto result = + manager.listener_session_events(listener_id, session_id, after, limit); + reply({{"type", "result"}, + {"ok", result.value("ok", false)}, + {"error", result.value("error", std::string())}, + {"events", result.value("events", nlohmann::json::array())}, + {"session", result.value("session", nlohmann::json::object())}, + {"nextSeq", result.value("nextSeq", 0)}}); + } else if (type == "send") { + std::string error; + const bool ok = manager.listener_send( + listener_id, message.value("sessionId", std::string()), + message.value("peerHost", std::string()), message.value("peerPort", 0), + message.value("payload", std::string()), + message.value("encoding", std::string("text")), &error); + reply({{"type", "result"}, {"ok", ok}, {"error", error}}); + } else if (type == "close-session") { + std::string error; + const bool ok = manager.listener_close_session( + listener_id, message.value("sessionId", std::string()), &error); + reply({{"type", "result"}, {"ok", ok}, {"error", error}}); + } else if (type == "stop") { + telemetry.started = false; + if (!listener_id.empty()) { + manager.stop_listener(listener_id, nullptr); + manager.remove_listener(listener_id, nullptr); + } + reply({{"type", "stopped"}}); + channel.close(); + } + }); + channel.set_on_close([&loop, &telemetry](const std::string&) { + telemetry.started = false; + uv_stop(&loop); + }); + std::string error; + if (!channel.connect(endpoint, &error)) { + uv_loop_close(&loop); + return 1; + } + uv_timer_t telemetry_timer; + uv_timer_init(&loop, &telemetry_timer); + telemetry_timer.data = &telemetry; + uv_timer_start( + &telemetry_timer, + [](uv_timer_t* timer) { + auto* state = static_cast(timer->data); + if (state == nullptr || !state->started || state->id.empty()) return; + const auto current = state->manager->listener(state->id); + const auto events = state->manager->listener_events(state->id, + state->last_event_seq, 200); + const auto sessions = state->manager->listener_sessions(state->id); + const auto events_array = events.value("events", nlohmann::json::array()); + if (events_array.is_array() && !events_array.empty()) { + state->last_event_seq = events_array.back().value("seq", state->last_event_seq); + } + std::string send_error; + state->channel->send( + {{"type", "telemetry"}, + {"listener", current.value("listener", nlohmann::json::object())}, + {"events", events_array}, + {"sessions", sessions.value("sessions", nlohmann::json::array())}}, + &send_error); + }, + 500, 500); + uv_run(&loop, UV_RUN_DEFAULT); + channel.close(); + uv_timer_stop(&telemetry_timer); + uv_close(reinterpret_cast(&telemetry_timer), nullptr); + for (int i = 0; i < 8 && uv_loop_alive(&loop); ++i) uv_run(&loop, UV_RUN_NOWAIT); + uv_loop_close(&loop); + return 0; +} + +struct ListenerChildService::Impl { + Impl(std::string path, ListenerConfig listener_config) + : executable(std::move(path)), config(std::move(listener_config)) {} + + std::string executable; + mutable std::mutex mutex; + mutable std::condition_variable cv; + ListenerConfig config; + uv_loop_t loop{}; + IpcChannel* server = nullptr; + uv_process_t process{}; + bool process_spawned = false; + bool process_closed = false; + mutable uv_async_t wake{}; + bool wake_initialized = false; + mutable std::deque commands; + std::thread thread; + std::atomic is_running{false}; + std::atomic stopping{false}; + bool loop_ready = false; + bool start_result_ready = false; + bool start_ok = false; + std::string error; + nlohmann::json snapshot = nlohmann::json::object(); + nlohmann::json event_cache = nlohmann::json::array(); + nlohmann::json session_cache = nlohmann::json::array(); + std::uint64_t last_event_seq = 0; + int child_pid = 0; + std::string endpoint; + mutable bool request_pending = false; + mutable nlohmann::json request_result = nlohmann::json::object(); + mutable std::string request_error; + + ListenerConfig config_copy() const { + std::lock_guard lock(mutex); + return config; + } + + void update_telemetry(const nlohmann::json& message) { + { + std::lock_guard lock(mutex); + const auto current = message.value("listener", nlohmann::json::object()); + if (current.is_object()) snapshot = current; + const auto sessions = message.value("sessions", nlohmann::json::array()); + if (sessions.is_array()) session_cache = sessions; + } + const auto events = message.value("events", nlohmann::json::array()); + if (!events.is_array()) return; + std::lock_guard lock(mutex); + for (const auto& event : events) { + if (!event.is_object()) continue; + const std::uint64_t seq = event.value("seq", 0); + if (seq == 0 || seq <= last_event_seq) continue; + event_cache.push_back(event); + last_event_seq = seq; + } + while (event_cache.size() > 500) event_cache.erase(event_cache.begin()); + } + + void on_message(const nlohmann::json& message) { + if (!message.is_object()) return; + const std::string type = message.value("type", std::string()); + if (type == "started") { + { + std::lock_guard lock(mutex); + const auto current = message.value("listener", nlohmann::json::object()); + if (current.is_object()) snapshot = current; + is_running.store(true); + start_ok = true; + start_result_ready = true; + } + cv.notify_all(); + return; + } + if (type == "telemetry") { + update_telemetry(message); + return; + } + if (type == "result") { + { + std::lock_guard lock(mutex); + request_result = message; + request_error = message.value("ok", false) + ? std::string() + : message.value("error", std::string("request failed")); + request_pending = false; + } + cv.notify_all(); + return; + } + if (type == "error") { + { + std::lock_guard lock(mutex); + start_ok = false; + start_result_ready = true; + error = message.value("error", std::string("listener child error")); + is_running.store(false); + } + cv.notify_all(); + uv_stop(&loop); + return; + } + if (type == "stopped") { + { + std::lock_guard lock(mutex); + is_running.store(false); + stopping.store(true); + } + cv.notify_all(); + } + } + + void on_wake() { + std::deque pending; + { + std::lock_guard lock(mutex); + pending.swap(commands); + } + for (const auto& command : pending) { + if (command.value("type", std::string()) == "shutdown") { + uv_stop(&loop); + continue; + } + if (server != nullptr) { + std::string send_error; + server->send(command, &send_error); + } + } + } + + static void on_wake_cb(uv_async_t* handle) { + auto* self = static_cast(handle->data); + if (self != nullptr) self->on_wake(); + } + + static void on_exit_cb(uv_process_t* child, int64_t, int) { + auto* self = static_cast(child->data); + if (self == nullptr) return; + { + std::lock_guard lock(self->mutex); + self->process_closed = true; + self->is_running.store(false); + if (!self->stopping.load() && self->error.empty()) { + self->error = "listener child exited"; + } + } + uv_close(reinterpret_cast(child), nullptr); + uv_stop(&self->loop); + self->cv.notify_all(); + } + + void set_start_error(const std::string& reason) { + { + std::lock_guard lock(mutex); + if (start_result_ready) return; + start_ok = false; + start_result_ready = true; + error = reason; + is_running.store(false); + } + cv.notify_all(); + uv_stop(&loop); + } + + void post(nlohmann::json message) const { + bool signal = false; + { + std::lock_guard lock(mutex); + commands.push_back(std::move(message)); + signal = wake_initialized; + } + if (signal) uv_async_send(&wake); + } + + nlohmann::json request(const nlohmann::json& command, int timeout_ms, + std::string* error) const { + { + std::lock_guard lock(mutex); + if (!is_running.load()) { + if (error) *error = "listener child is not running"; + return {}; + } + if (request_pending) { + if (error) *error = "another listener request is in flight"; + return {}; + } + request_pending = true; + request_result = nlohmann::json::object(); + request_error.clear(); + } + post(command); + std::unique_lock lock(mutex); + if (!cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), + [this] { return !request_pending; })) { + request_pending = false; + if (error) *error = "listener child request timed out"; + return {}; + } + if (!request_error.empty()) { + if (error) *error = request_error; + return {}; + } + return request_result; + } + + void loop_main() { + if (uv_loop_init(&loop) != 0) { + { + std::lock_guard lock(mutex); + start_ok = false; + start_result_ready = true; + error = "could not initialize the listener child IPC loop"; + } + cv.notify_all(); + return; + } + endpoint = ipc_endpoint_path("listener-svc-" + random_hex(6)); + std::error_code ec; + std::filesystem::remove(endpoint, ec); + server = new IpcChannel(&loop); + server->set_on_ready([this] { + std::string send_error; + if (!server->send( + {{"type", "start"}, + {"config", listener_config_to_json(config_copy())}}, + &send_error)) { + set_start_error(send_error); + } + }); + server->set_on_message( + [this](const nlohmann::json& message) { on_message(message); }); + server->set_on_close([this](const std::string& reason) { + { + std::lock_guard lock(mutex); + if (!stopping.load() && error.empty()) error = reason; + is_running.store(false); + } + cv.notify_all(); + }); + std::string listen_error; + if (!server->listen(endpoint, &listen_error)) { + set_start_error(listen_error); + server->close(); + for (int i = 0; i < 8 && uv_loop_alive(&loop); ++i) uv_run(&loop, UV_RUN_NOWAIT); + uv_loop_close(&loop); + delete server; + server = nullptr; + { + std::lock_guard lock(mutex); + loop_ready = false; + } + cv.notify_all(); + return; + } + if (uv_async_init(&loop, &wake, on_wake_cb) != 0) { + set_start_error("could not initialize the listener child command channel"); + server->close(); + for (int i = 0; i < 8 && uv_loop_alive(&loop); ++i) uv_run(&loop, UV_RUN_NOWAIT); + uv_loop_close(&loop); + delete server; + server = nullptr; + { + std::lock_guard lock(mutex); + loop_ready = false; + } + cv.notify_all(); + return; + } + wake.data = this; + { + std::lock_guard lock(mutex); + wake_initialized = true; + } + std::string exe = executable; + if (exe.empty()) { +#if defined(_WIN32) + exe = (executable_dir(nullptr) / "uvscope.exe").string(); +#else + exe = (executable_dir(nullptr) / "uvscope").string(); +#endif + } + std::string exe_copy = exe; + const char* args[] = {exe_copy.c_str(), "--listener-child", endpoint.c_str(), nullptr}; + uv_process_options_t options{}; + options.file = exe_copy.c_str(); + options.args = const_cast(args); + options.exit_cb = on_exit_cb; + const int spawn_rc = uv_spawn(&loop, &process, &options); + if (spawn_rc != 0) { + set_start_error(std::string("could not spawn the listener child: ") + + uv_strerror(spawn_rc)); + server->close(); + uv_close(reinterpret_cast(&wake), nullptr); + for (int i = 0; i < 8 && uv_loop_alive(&loop); ++i) uv_run(&loop, UV_RUN_NOWAIT); + uv_loop_close(&loop); + delete server; + server = nullptr; + { + std::lock_guard lock(mutex); + wake_initialized = false; + loop_ready = false; + } + cv.notify_all(); + return; + } + process.data = this; + process_spawned = true; + { + std::lock_guard lock(mutex); + child_pid = process.pid; + loop_ready = true; + } + cv.notify_all(); + + uv_run(&loop, UV_RUN_DEFAULT); + if (server != nullptr) server->close(); + if (wake_initialized) { + uv_close(reinterpret_cast(&wake), nullptr); + wake_initialized = false; + } + if (process_spawned && !process_closed) { + uv_process_kill(&process, SIGTERM); + for (int i = 0; i < 50 && !process_closed; ++i) { + uv_run(&loop, UV_RUN_NOWAIT); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (!process_closed) { + process_closed = true; + uv_close(reinterpret_cast(&process), nullptr); + } + } + for (int i = 0; i < 8 && uv_loop_alive(&loop); ++i) uv_run(&loop, UV_RUN_NOWAIT); + uv_loop_close(&loop); + delete server; + server = nullptr; + { + std::lock_guard lock(mutex); + is_running.store(false); + loop_ready = false; + } + cv.notify_all(); + std::filesystem::remove(endpoint, ec); + } +}; + +ListenerChildService::ListenerChildService(std::string executable, ListenerConfig config) + : impl_(new Impl(std::move(executable), std::move(config))) {} + +ListenerChildService::~ListenerChildService() { stop(); } + +bool ListenerChildService::start(std::string* error) { + { + std::lock_guard lock(impl_->mutex); + if (impl_->thread.joinable()) { + if (error) *error = "listener child is already started"; + return false; + } + impl_->start_result_ready = false; + impl_->start_ok = false; + impl_->error.clear(); + impl_->stopping.store(false); + } + impl_->thread = std::thread([this] { impl_->loop_main(); }); + std::unique_lock lock(impl_->mutex); + if (!impl_->cv.wait_for(lock, std::chrono::seconds(10), [this] { + return impl_->start_result_ready; + })) { + if (error) *error = "listener child start timed out"; + lock.unlock(); + stop(); + return false; + } + if (!impl_->start_ok) { + if (error) *error = impl_->error.empty() ? "listener child failed to start" + : impl_->error; + return false; + } + return true; +} + +void ListenerChildService::stop() { + if (impl_ == nullptr || !impl_->thread.joinable()) return; + impl_->stopping.store(true); + if (impl_->is_running.load()) impl_->post({{"type", "stop"}}); + { + std::unique_lock lock(impl_->mutex); + impl_->cv.wait_for(lock, std::chrono::seconds(5), [this] { + return !impl_->is_running.load(); + }); + } + bool loop_ready = false; + { + std::unique_lock lock(impl_->mutex); + impl_->cv.wait_for(lock, std::chrono::seconds(5), [this] { + return !impl_->loop_ready; + }); + loop_ready = impl_->loop_ready; + } + if (loop_ready) impl_->post({{"type", "shutdown"}}); + impl_->thread.join(); + impl_->is_running.store(false); +} + +bool ListenerChildService::running() const { return impl_->is_running.load(); } + +nlohmann::json ListenerChildService::snapshot() const { + std::lock_guard lock(impl_->mutex); + nlohmann::json out = impl_->snapshot.is_object() ? impl_->snapshot + : nlohmann::json::object(); + out["id"] = impl_->config.id; + out["transport"] = impl_->config.transport; + out["name"] = impl_->config.name; + out["host"] = impl_->config.host; + out["port"] = impl_->config.port; + out["isolated"] = true; + out["pid"] = impl_->child_pid; + out["running"] = impl_->is_running.load(); + if (!impl_->is_running.load()) { + out["state"] = impl_->stopping.load() ? "stopped" : "error"; + if (!impl_->error.empty()) out["error"] = impl_->error; + } + return out; +} + +nlohmann::json ListenerChildService::events(std::uint64_t after, int limit) const { + std::lock_guard lock(impl_->mutex); + nlohmann::json out = nlohmann::json::array(); + for (const auto& event : impl_->event_cache) { + if (!event.is_object() || event.value("seq", 0) <= after) continue; + out.push_back(event); + if (static_cast(out.size()) >= std::max(1, limit)) break; + } + return out; +} + +nlohmann::json ListenerChildService::sessions() const { + std::lock_guard lock(impl_->mutex); + return impl_->session_cache; +} + +nlohmann::json ListenerChildService::session_events(const std::string& session_id, + std::uint64_t after, int limit, + std::string* error) const { + const auto result = impl_->request( + {{"type", "session-events"}, {"sessionId", session_id}, {"after", after}, + {"limit", limit}}, + 5000, error); + if (!result.is_object()) return {}; + return result; +} + +bool ListenerChildService::send(const std::string& session_id, + const std::string& peer_host, int peer_port, + const std::string& payload, + const std::string& encoding, std::string* error) { + const auto result = impl_->request( + {{"type", "send"}, {"sessionId", session_id}, {"peerHost", peer_host}, + {"peerPort", peer_port}, {"payload", payload}, {"encoding", encoding}}, + 5000, error); + if (!result.is_object()) return false; + if (!result.value("ok", false)) { + if (error) *error = result.value("error", std::string("listener send failed")); + return false; + } + return true; +} + +bool ListenerChildService::close_session(const std::string& session_id, + std::string* error) { + const auto result = impl_->request( + {{"type", "close-session"}, {"sessionId", session_id}}, 5000, error); + if (!result.is_object()) return false; + if (!result.value("ok", false)) { + if (error) *error = result.value("error", std::string("listener close failed")); + return false; + } + return true; +} + +int ListenerChildService::pid() const { + std::lock_guard lock(impl_->mutex); + return impl_->child_pid; +} + +} // namespace uvscope diff --git a/src/listener_child.h b/src/listener_child.h new file mode 100644 index 0000000..f012bdf --- /dev/null +++ b/src/listener_child.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include + +#include "store.h" + +namespace uvscope { + +// Child mode: run one TCP/UDP listener from IPC commands. +int run_listener_child(const std::string& endpoint); + +// Parent-side handle for one listener running in a child process. +class ListenerChildService { + public: + ListenerChildService(std::string executable, ListenerConfig config); + ~ListenerChildService(); + + ListenerChildService(const ListenerChildService&) = delete; + ListenerChildService& operator=(const ListenerChildService&) = delete; + + bool start(std::string* error); + void stop(); + + bool running() const; + nlohmann::json snapshot() const; + nlohmann::json events(std::uint64_t after, int limit) const; + nlohmann::json sessions() const; + nlohmann::json session_events(const std::string& session_id, + std::uint64_t after, int limit, + std::string* error) const; + bool send(const std::string& session_id, const std::string& peer_host, + int peer_port, const std::string& payload, + const std::string& encoding, std::string* error); + bool close_session(const std::string& session_id, std::string* error); + int pid() const; + + private: + struct Impl; + std::unique_ptr impl_; +}; + +nlohmann::json listener_config_to_json(const ListenerConfig& config); +bool listener_config_from_json(const nlohmann::json& value, ListenerConfig* config, + std::string* error); + +} // namespace uvscope diff --git a/src/main.cpp b/src/main.cpp index 8a91828..9a9e9d6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,7 @@ #include "application.h" #include "ipc.h" +#include "listener_child.h" #include "proxy_child.h" #include "socks5_child.h" #include "util.h" @@ -61,6 +62,7 @@ int main(int argc, char** argv) { std::string ipc_child_endpoint; std::string proxy_child_endpoint; std::string socks5_child_endpoint; + std::string listener_child_endpoint; bool show_help = false; for (int i = 1; i < argc; ++i) { @@ -98,6 +100,8 @@ int main(int argc, char** argv) { proxy_child_endpoint = require_value("--proxy-child"); } else if (arg == "--socks5-child") { socks5_child_endpoint = require_value("--socks5-child"); + } else if (arg == "--listener-child") { + listener_child_endpoint = require_value("--listener-child"); } else if (arg == "--version" || arg == "-v") { std::printf("uvscope %s\n", UVSCOPE_VERSION); return 0; @@ -123,6 +127,9 @@ int main(int argc, char** argv) { if (!socks5_child_endpoint.empty()) { return uvscope::run_socks5_child(socks5_child_endpoint); } + if (!listener_child_endpoint.empty()) { + return uvscope::run_listener_child(listener_child_endpoint); + } uvscope::Application app(std::move(options)); return app.run(); diff --git a/src/store.cpp b/src/store.cpp index e3a29ce..6a632f8 100644 --- a/src/store.cpp +++ b/src/store.cpp @@ -1018,6 +1018,7 @@ std::vector Store::listeners() const { listener.host = item.value("host", std::string("127.0.0.1")); listener.port = item.value("port", 0); listener.auto_start = item.value("autoStart", false); + listener.isolated = item.value("isolated", false); listener.created_at = item.value("createdAt", std::string()); listener.updated_at = item.value("updatedAt", std::string()); if (listener.id.empty() || (listener.transport != "tcp" && listener.transport != "udp") || @@ -1067,6 +1068,7 @@ bool Store::save_listener(const ListenerConfig& listener, std::string* error) { {"host", item.host}, {"port", item.port}, {"autoStart", item.auto_start}, + {"isolated", item.isolated}, {"createdAt", item.created_at}, {"updatedAt", item.updated_at}}); } @@ -1091,6 +1093,7 @@ bool Store::delete_listener(const std::string& id, std::string* error) { {"host", item.host}, {"port", item.port}, {"autoStart", item.auto_start}, + {"isolated", item.isolated}, {"createdAt", item.created_at}, {"updatedAt", item.updated_at}}); } diff --git a/src/store.h b/src/store.h index bfa7820..575fbb3 100644 --- a/src/store.h +++ b/src/store.h @@ -67,6 +67,7 @@ struct ListenerConfig { std::string host = "127.0.0.1"; int port = 0; bool auto_start = false; + bool isolated = false; std::string created_at; std::string updated_at; }; diff --git a/src/transport_manager.cpp b/src/transport_manager.cpp index 726bc91..aa38218 100644 --- a/src/transport_manager.cpp +++ b/src/transport_manager.cpp @@ -1,5 +1,7 @@ #include "transport_manager.h" +#include "listener_child.h" + #include #include #include @@ -141,6 +143,7 @@ struct TransportManager::Impl { struct Listener { ListenerConfig config; + std::shared_ptr child; std::string state = "stopped"; std::string error; std::string started_at; @@ -182,6 +185,7 @@ struct TransportManager::Impl { bool accepting = true; std::vector> commands; + std::string executable_path; mutable std::mutex state_mutex; std::map> sessions; @@ -333,6 +337,8 @@ struct TransportManager::Impl { {"host", listener.config.host}, {"port", listener.config.port}, {"autoStart", listener.config.auto_start}, + {"isolated", listener.config.isolated}, + {"pid", 0}, {"state", state_name(listener.state)}, {"error", listener.error}, {"startedAt", listener.started_at}, @@ -1253,7 +1259,8 @@ struct TransportManager::Impl { } }; -TransportManager::TransportManager() : impl_(new Impl()) { +TransportManager::TransportManager(std::string executable_path) : impl_(new Impl()) { + impl_->executable_path = std::move(executable_path); impl_->thread = std::thread([this] { impl_->run_loop(); }); std::unique_lock lock(impl_->ready_mutex); impl_->ready_cv.wait(lock, [this] { return impl_->ready; }); @@ -1472,15 +1479,40 @@ bool TransportManager::add_listener(const ListenerConfig& config, } bool TransportManager::start_listener(const std::string& id, std::string* error) { + std::shared_ptr listener; { std::lock_guard lock(impl_->state_mutex); - auto listener = impl_->listener_locked(id); + listener = impl_->listener_locked(id); if (!listener) { if (error) *error = "listener not found"; return false; } - if (listener->state == "listening") return true; - listener->state = "starting"; + if (listener->config.isolated) { + if (listener->child && listener->child->running()) return true; + } else { + if (listener->state == "listening") return true; + listener->state = "starting"; + } + } + if (listener->config.isolated) { + listener->child.reset( + new ListenerChildService(impl_->executable_path, listener->config)); + std::string child_error; + if (!listener->child->start(&child_error)) { + std::lock_guard lock(impl_->state_mutex); + listener->state = "error"; + listener->error = child_error.empty() ? "listener child failed to start" + : child_error; + listener->child.reset(); + if (error) *error = listener->error; + return false; + } + const auto snapshot = listener->child->snapshot(); + std::lock_guard lock(impl_->state_mutex); + listener->state = snapshot.value("state", std::string("listening")); + listener->error.clear(); + listener->started_at = now_iso8601(); + return true; } if (!impl_->post([this, id] { impl_->start_listener_loop(id); })) { if (error) *error = "transport manager is stopping"; @@ -1490,14 +1522,25 @@ bool TransportManager::start_listener(const std::string& id, std::string* error) } bool TransportManager::stop_listener(const std::string& id, std::string* error) { + std::shared_ptr listener; { std::lock_guard lock(impl_->state_mutex); - auto listener = impl_->listener_locked(id); + listener = impl_->listener_locked(id); if (!listener) { if (error) *error = "listener not found"; return false; } - if (listener->state == "stopped") return true; + if (!listener->config.isolated && listener->state == "stopped") return true; + } + if (listener->config.isolated) { + if (listener->child) { + listener->child->stop(); + listener->child.reset(); + } + std::lock_guard lock(impl_->state_mutex); + listener->state = "stopped"; + listener->stopped_at = now_iso8601(); + return true; } if (!impl_->post([this, id] { impl_->stop_listener_loop(id); })) { if (error) *error = "transport manager is stopping"; @@ -1507,13 +1550,24 @@ bool TransportManager::stop_listener(const std::string& id, std::string* error) } bool TransportManager::remove_listener(const std::string& id, std::string* error) { + std::shared_ptr listener; { std::lock_guard lock(impl_->state_mutex); - if (!impl_->listener_locked(id)) { + listener = impl_->listener_locked(id); + if (!listener) { if (error) *error = "listener not found"; return false; } } + if (listener->config.isolated) { + if (listener->child) { + listener->child->stop(); + listener->child.reset(); + } + std::lock_guard lock(impl_->state_mutex); + impl_->listeners.erase(id); + return true; + } if (!impl_->post([this, id] { impl_->stop_listener_loop(id); std::lock_guard lock(impl_->state_mutex); @@ -1529,7 +1583,11 @@ nlohmann::json TransportManager::listeners() const { std::lock_guard lock(impl_->state_mutex); nlohmann::json out = nlohmann::json::array(); for (const auto& item : impl_->listeners) { - out.push_back(impl_->listener_snapshot_locked(*item.second)); + if (item.second->child) { + out.push_back(item.second->child->snapshot()); + } else { + out.push_back(impl_->listener_snapshot_locked(*item.second)); + } } std::sort(out.begin(), out.end(), [](const nlohmann::json& a, const nlohmann::json& b) { return a.value("id", std::string()) < b.value("id", std::string()); @@ -1541,6 +1599,9 @@ nlohmann::json TransportManager::listener(const std::string& id) const { std::lock_guard lock(impl_->state_mutex); auto listener = impl_->listener_locked(id); if (!listener) return {{"ok", false}, {"error", "listener not found"}}; + if (listener->child) { + return {{"ok", true}, {"listener", listener->child->snapshot()}}; + } return {{"ok", true}, {"listener", impl_->listener_snapshot_locked(*listener)}}; } @@ -1551,6 +1612,15 @@ nlohmann::json TransportManager::listener_events(const std::string& id, auto listener = impl_->listener_locked(id); if (!listener) return {{"ok", false}, {"error", "listener not found"}}; const int safe_limit = std::max(1, std::min(limit, 500)); + if (listener->child) { + const auto events = listener->child->events(after, safe_limit); + const std::uint64_t next = + events.is_array() && !events.empty() ? events.back().value("seq", after) : after; + return {{"ok", true}, + {"listener", listener->child->snapshot()}, + {"events", events}, + {"nextSeq", next}}; + } nlohmann::json events = nlohmann::json::array(); for (const auto& event : listener->events) { if (event.json.value("seq", std::uint64_t{0}) <= after) continue; @@ -1567,6 +1637,9 @@ nlohmann::json TransportManager::listener_sessions(const std::string& id) const std::lock_guard lock(impl_->state_mutex); auto listener = impl_->listener_locked(id); if (!listener) return {{"ok", false}, {"error", "listener not found"}}; + if (listener->child) { + return {{"ok", true}, {"sessions", listener->child->sessions()}}; + } nlohmann::json sessions = nlohmann::json::array(); for (const auto& item : listener->sessions) { auto snapshot = impl_->accepted_snapshot_locked(*item.second); @@ -1585,6 +1658,14 @@ nlohmann::json TransportManager::listener_session_events( std::lock_guard lock(impl_->state_mutex); auto listener = impl_->listener_locked(id); if (!listener) return {{"ok", false}, {"error", "listener not found"}}; + if (listener->child) { + std::string error; + auto result = listener->child->session_events(session_id, after, limit, &error); + if (!result.is_object()) { + return {{"ok", false}, {"error", error.empty() ? "session not found" : error}}; + } + return result; + } auto it = listener->sessions.find(session_id); if (it == listener->sessions.end()) { return {{"ok", false}, {"error", "session not found"}}; @@ -1608,6 +1689,24 @@ bool TransportManager::listener_send(const std::string& id, const std::string& payload, const std::string& encoding, std::string* error) { + std::shared_ptr isolated; + { + std::lock_guard lock(impl_->state_mutex); + auto listener = impl_->listener_locked(id); + if (!listener) { + if (error) *error = "listener not found"; + return false; + } + if (listener->config.isolated) isolated = listener; + } + if (isolated) { + if (!isolated->child || !isolated->child->running()) { + if (error) *error = "listener is not running"; + return false; + } + return isolated->child->send(session_id, peer_host, peer_port, payload, + encoding, error); + } std::string bytes; if (!decode_payload(payload, encoding, &bytes, error)) return false; if (bytes.empty()) { @@ -1667,6 +1766,23 @@ bool TransportManager::listener_send(const std::string& id, bool TransportManager::listener_close_session(const std::string& id, const std::string& session_id, std::string* error) { + std::shared_ptr isolated; + { + std::lock_guard lock(impl_->state_mutex); + auto listener = impl_->listener_locked(id); + if (!listener) { + if (error) *error = "listener not found"; + return false; + } + if (listener->config.isolated) isolated = listener; + } + if (isolated) { + if (!isolated->child || !isolated->child->running()) { + if (error) *error = "listener is not running"; + return false; + } + return isolated->child->close_session(session_id, error); + } { std::lock_guard lock(impl_->state_mutex); auto listener = impl_->listener_locked(id); diff --git a/src/transport_manager.h b/src/transport_manager.h index ebcdfa1..0ca47f7 100644 --- a/src/transport_manager.h +++ b/src/transport_manager.h @@ -16,7 +16,7 @@ namespace uvscope { // stalled target can never block the control plane. class TransportManager { public: - TransportManager(); + explicit TransportManager(std::string executable_path = ""); ~TransportManager(); TransportManager(const TransportManager&) = delete; diff --git a/web/src/i18n/listeners.ts b/web/src/i18n/listeners.ts index 9c447cd..df46e15 100644 --- a/web/src/i18n/listeners.ts +++ b/web/src/i18n/listeners.ts @@ -13,6 +13,7 @@ const en: Record = { 'listeners.create.host': 'Host', 'listeners.create.port': 'Port', 'listeners.create.autoStart': 'Start with the daemon', + 'listeners.create.isolated': 'Run in a child process', 'listeners.create.startNow': 'Start now', 'listeners.create.action': 'Create listener', 'listeners.create.creating': 'Creating…', @@ -105,6 +106,7 @@ const zh: Record = { 'listeners.create.host': '监听地址', 'listeners.create.port': '端口', 'listeners.create.autoStart': '随守护进程启动', + 'listeners.create.isolated': '在子进程中运行', 'listeners.create.startNow': '立即启动', 'listeners.create.action': '创建监听', 'listeners.create.creating': '正在创建…', diff --git a/web/src/views/ListenersView.vue b/web/src/views/ListenersView.vue index 0df7705..61f6d92 100644 --- a/web/src/views/ListenersView.vue +++ b/web/src/views/ListenersView.vue @@ -14,6 +14,8 @@ interface ListenerInfo { host: string port: number autoStart?: boolean + isolated?: boolean + pid?: number state: 'stopped' | 'starting' | 'listening' | 'stopping' | 'error' error?: string startedAt?: string @@ -62,6 +64,7 @@ const name = ref('') const host = ref('127.0.0.1') const port = ref(9000) const autoStart = ref(false) +const isolated = ref(false) const startNow = ref(true) const creating = ref(false) @@ -178,6 +181,7 @@ async function createListener() { port: port.value, autoStart: autoStart.value, start: startNow.value, + isolated: isolated.value, }, ) if (!response.ok || !response.listener) { @@ -481,6 +485,7 @@ onBeforeUnmount(stopPolling)
+