From 2b0901eb624bfb05e7b997a5a9e1bcbee036f01c Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 20 Jul 2026 08:45:13 +0900 Subject: [PATCH] runtime: avoid websocket runtime drop panic --- crates/worker/src/feature/plugin.rs | 56 +++++++++-- scripts/dev-workspace.sh | 148 ++++++++++++++++++++++++---- 2 files changed, 174 insertions(+), 30 deletions(-) diff --git a/crates/worker/src/feature/plugin.rs b/crates/worker/src/feature/plugin.rs index fe6ca886..7b0b322e 100644 --- a/crates/worker/src/feature/plugin.rs +++ b/crates/worker/src/feature/plugin.rs @@ -2552,8 +2552,39 @@ struct TungstenitePluginWebSocketClient; type AsyncSystemWebSocket = tokio_tungstenite::WebSocketStream>; +struct PluginWebSocketRuntime { + runtime: Option, +} + +impl PluginWebSocketRuntime { + fn new(runtime: TokioRuntime) -> Self { + Self { + runtime: Some(runtime), + } + } + + fn get(&self) -> &TokioRuntime { + self.runtime + .as_ref() + .expect("plugin websocket runtime missing") + } +} + +impl Drop for PluginWebSocketRuntime { + fn drop(&mut self) { + let Some(runtime) = self.runtime.take() else { + return; + }; + if TokioHandle::try_current().is_ok() { + let _ = tokio::task::spawn_blocking(move || drop(runtime)); + } else { + drop(runtime); + } + } +} + struct TungstenitePluginWebSocketConnection { - runtime: TokioRuntime, + runtime: PluginWebSocketRuntime, socket: AsyncSystemWebSocket, } @@ -2592,7 +2623,7 @@ impl PluginWebSocketClient for TungstenitePluginWebSocketClient { ) .await }; - let (socket, _response) = block_on_websocket_future(&runtime, open) + let (socket, _response) = block_on_websocket_future(runtime.get(), open) .map_err(|error| { PluginWebSocketError::new(format!( "WebSocket open timed out after {} ms for {}: {error}", @@ -2619,7 +2650,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.send(Message::Text(text.to_string().into())), ); - block_on_websocket_future(&self.runtime, send) + block_on_websocket_future(self.runtime.get(), send) .map_err(|_| PluginWebSocketError::new("WebSocket send timed out"))? .map_err(|error| PluginWebSocketError::new(format!("WebSocket send failed: {error}"))) } @@ -2631,7 +2662,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { ) -> Result { for _ in 0..PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES { let next = tokio::time::timeout(timeout, self.socket.next()); - let message = block_on_websocket_future(&self.runtime, next) + let message = block_on_websocket_future(self.runtime.get(), next) .map_err(|_| PluginWebSocketError::new("WebSocket receive timed out"))? .ok_or_else(|| PluginWebSocketError::new("WebSocket stream ended"))? .map_err(|error| { @@ -2660,7 +2691,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.send(Message::Pong(payload)), ); - block_on_websocket_future(&self.runtime, send) + block_on_websocket_future(self.runtime.get(), send) .map_err(|_| PluginWebSocketError::new("WebSocket pong timed out"))? .map_err(|error| { PluginWebSocketError::new(format!("WebSocket pong failed: {error}")) @@ -2676,19 +2707,20 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { fn close(&mut self) -> Result<(), PluginWebSocketError> { let close = tokio::time::timeout(PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.close(None)); - block_on_websocket_future(&self.runtime, close) + block_on_websocket_future(self.runtime.get(), close) .map_err(|_| PluginWebSocketError::new("WebSocket close timed out"))? .map_err(|error| PluginWebSocketError::new(format!("WebSocket close failed: {error}"))) } } -fn new_websocket_runtime() -> Result { - TokioRuntimeBuilder::new_current_thread() +fn new_websocket_runtime() -> Result { + let runtime = TokioRuntimeBuilder::new_current_thread() .enable_all() .build() .map_err(|error| { PluginWebSocketError::new(format!("WebSocket runtime build failed: {error}")) - }) + })?; + Ok(PluginWebSocketRuntime::new(runtime)) } fn block_on_websocket_future( @@ -5656,6 +5688,12 @@ mod tests { use std::sync::{Arc, Mutex}; use tempfile::TempDir; + #[tokio::test(flavor = "multi_thread")] + async fn websocket_runtime_drop_is_safe_inside_async_context() { + let runtime = new_websocket_runtime().unwrap(); + drop(runtime); + } + fn tool(name: &str) -> manifest::plugin::PluginToolManifest { manifest::plugin::PluginToolManifest { name: name.into(), diff --git a/scripts/dev-workspace.sh b/scripts/dev-workspace.sh index af339899..5017796b 100755 --- a/scripts/dev-workspace.sh +++ b/scripts/dev-workspace.sh @@ -32,6 +32,9 @@ FRONTEND_PORT="${YOI_DEV_FRONTEND_PORT:-5173}" RUNTIME_ENABLED="${YOI_DEV_RUNTIME_ENABLED:-1}" ACTION_DELAY_SECONDS="${YOI_DEV_ACTION_DELAY_SECONDS:-60}" +WORKDIR_ID="$(basename "$(dirname "$(dirname "$ROOT_DIR")")")" +UNIT_PREFIX="${YOI_DEV_SYSTEMD_UNIT_PREFIX:-yoi-dev-$WORKDIR_ID}" +USE_SYSTEMD="${YOI_DEV_USE_SYSTEMD:-1}" FOREGROUND_MODE="${YOI_DEV_WORKSPACE_FOREGROUND:-0}" usage() { @@ -39,8 +42,8 @@ usage() { Usage: $(basename "$0") Manage the local Yoi development stack for this checkout: - runtime cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --bind $RUNTIME_BIND - backend cargo run -p yoi-workspace-server -- serve --workspace $ROOT_DIR --db $ROOT_DIR/.yoi/workspace.db --listen $BACKEND_LISTEN + runtime target/debug/worker-runtime-rest-server --bind $RUNTIME_BIND + backend target/debug/yoi-workspace-server serve --workspace $ROOT_DIR --db $ROOT_DIR/.yoi/workspace.db --listen $BACKEND_LISTEN frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace) Actions: @@ -49,7 +52,7 @@ Actions: restart schedule a detached job that stops/starts runtime and backend only; frontend is left untouched status print pidfile and port-listener status without mutating processes -By default, start/stop/restart return immediately and run in a detached job after $ACTION_DELAY_SECONDS seconds. +By default, start/stop/restart return immediately and run in a fully detached nohup+setsid job after $ACTION_DELAY_SECONDS seconds. This keeps API/tool-call sessions intact while the backend/runtime are restarted. Set YOI_DEV_WORKSPACE_FOREGROUND=1 to run the mutating action synchronously. @@ -69,6 +72,18 @@ log() { printf '[dev-workspace] %s\n' "$*" >&2 } +run_cargo() { + if command -v cc >/dev/null 2>&1 && command -v pkg-config >/dev/null 2>&1; then + cargo "$@" + return + fi + if command -v nix >/dev/null 2>&1 && [[ -f "$ROOT_DIR/flake.nix" ]]; then + nix develop "$ROOT_DIR" -c cargo "$@" + return + fi + cargo "$@" +} + ensure_dirs() { mkdir -p "$PID_DIR" "$LOG_DIR" } @@ -77,6 +92,24 @@ pid_file() { printf '%s/%s.pid' "$PID_DIR" "$1" } +systemd_unit_name() { + local name="$1" + printf '%s-%s.service' "$UNIT_PREFIX" "$name" +} + +systemd_available() { + [[ "$USE_SYSTEMD" != "0" ]] || return 1 + command -v systemd-run >/dev/null 2>&1 || return 1 + systemctl --user is-system-running >/dev/null 2>&1 || return 1 +} + +systemd_main_pid() { + local name="$1" + local unit + unit="$(systemd_unit_name "$name")" + systemctl --user show -P MainPID "$unit" 2>/dev/null | awk '$1 != "" && $1 != "0" { print $1; exit }' +} + log_file() { printf '%s/%s.log' "$LOG_DIR" "$1" } @@ -155,20 +188,27 @@ stop_pid() { stop_managed_service() { local name="$1" - local file + local file unit pid file="$(pid_file "$name")" - if [[ ! -f "$file" ]]; then - return 0 + + if systemd_available; then + unit="$(systemd_unit_name "$name")" + if systemctl --user is-active --quiet "$unit" 2>/dev/null; then + log "stopping $name systemd unit $unit" + systemctl --user stop "$unit" 2>/dev/null || true + fi fi - local pid - pid="$(cat "$file")" - if is_running "$pid"; then - stop_pid "$pid" "$name" + if [[ -f "$file" ]]; then + pid="$(cat "$file")" + if is_running "$pid"; then + stop_pid "$pid" "$name" + fi + rm -f "$file" fi - rm -f "$file" } + stop_port_listeners() { local label="$1" local port="$2" @@ -209,35 +249,88 @@ start_service() { stop_managed_service "$name" stop_port_listeners "$name" "$port" - local logfile pidfile + local logfile pidfile pid logfile="$(log_file "$name")" pidfile="$(pid_file "$name")" + : >"$logfile" + + if systemd_available; then + local unit + unit="$(systemd_unit_name "$name")" + log "starting $name systemd unit $unit; log: $logfile" + systemd-run --user --unit="$unit" --collect --same-dir --working-directory="$cwd" \ + --property="StandardOutput=append:$logfile" \ + --property="StandardError=append:$logfile" \ + --property="KillMode=control-group" \ + "$@" >/dev/null + for _ in {1..50}; do + pid="$(systemd_main_pid "$name" || true)" + if [[ -n "$pid" ]]; then + printf '%s\n' "$pid" >"$pidfile" + log "$name systemd pid $pid" + return 0 + fi + sleep 0.1 + done + printf '%s\n' "$name systemd unit did not expose MainPID" >&2 + return 1 + fi + log "starting $name; log: $logfile" ( cd "$cwd" exec setsid "$@" ) >"$logfile" 2>&1 & - local pid="$!" + pid="$!" printf '%s\n' "$pid" >"$pidfile" log "$name pid $pid" } + +build_runtime_backend() { + if [[ "$RUNTIME_ENABLED" != "0" ]]; then + log "building runtime binary" + ( + cd "$ROOT_DIR" + run_cargo build -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server + ) + else + log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0; skipping runtime build" + fi + + log "building backend binary" + ( + cd "$ROOT_DIR" + run_cargo build -p yoi-workspace-server --bin yoi-workspace-server + ) +} + start_runtime() { if [[ "$RUNTIME_ENABLED" == "0" ]]; then log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0" return 0 fi - local port + local port runtime_bin port="$(port_for_addr "$RUNTIME_BIND")" + runtime_bin="$ROOT_DIR/target/debug/worker-runtime-rest-server" + if [[ ! -x "$runtime_bin" ]]; then + printf 'runtime binary not found or not executable: %s\n' "$runtime_bin" >&2 + return 1 + fi start_service runtime "$ROOT_DIR" "$port" \ - cargo run -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server -- --bind "$RUNTIME_BIND" + "$runtime_bin" --bind "$RUNTIME_BIND" } start_backend() { - local port + local port backend_bin port="$(port_for_addr "$BACKEND_LISTEN")" + backend_bin="$ROOT_DIR/target/debug/yoi-workspace-server" + if [[ ! -x "$backend_bin" ]]; then + printf 'backend binary not found or not executable: %s\n' "$backend_bin" >&2 + return 1 + fi start_service backend "$ROOT_DIR" "$port" \ - cargo run -p yoi-workspace-server -- serve --workspace "$ROOT_DIR" --db "$ROOT_DIR/.yoi/workspace.db" --listen "$BACKEND_LISTEN" + "$backend_bin" serve --workspace "$ROOT_DIR" --db "$ROOT_DIR/.yoi/workspace.db" --listen "$BACKEND_LISTEN" } start_frontend() { @@ -256,6 +349,7 @@ stop_runtime_backend() { } start_runtime_backend() { + build_runtime_backend start_runtime start_backend } @@ -285,16 +379,26 @@ status_service() { local port="$2" local managed="-" local listeners="-" - if service_pid "$name" >/dev/null; then + local unit="-" + local systemd_pid="" + if systemd_available; then + unit="$(systemd_unit_name "$name")" + systemd_pid="$(systemd_main_pid "$name" || true)" + if [[ -n "$systemd_pid" ]]; then + managed="$systemd_pid" + fi + fi + if [[ "$managed" == "-" ]] && service_pid "$name" >/dev/null; then managed="$(service_pid "$name")" fi mapfile -t pids < <(listener_pids_for_port "$port" || true) if [[ "${#pids[@]}" -gt 0 ]]; then listeners="${pids[*]}" fi - printf '%-8s managed_pid=%-8s port=%-6s listener_pids=%s\n' "$name" "$managed" "$port" "$listeners" + printf '%-8s managed_pid=%-8s port=%-6s listener_pids=%-12s unit=%s\n' "$name" "$managed" "$port" "$listeners" "$unit" } + status_all() { ensure_dirs status_service runtime "$(port_for_addr "$RUNTIME_BIND")" @@ -311,7 +415,7 @@ schedule_detached_action() { job_log="$LOG_DIR/${action}-$stamp.job.log" log "scheduling $action in ${ACTION_DELAY_SECONDS}s; log: $job_log" - setsid bash -lc ' + nohup setsid bash -c ' set -euo pipefail delay="$1" root="$2" @@ -324,9 +428,11 @@ schedule_detached_action() { printf "[%s] dev-workspace %s finished with status %s\n" "$(date -Is)" "$action" "$status" exit "$status" ' dev-workspace-job "$ACTION_DELAY_SECONDS" "$ROOT_DIR" "$action" >>"$job_log" 2>&1 < /dev/null & + local scheduled_pid="$!" + disown "$scheduled_pid" 2>/dev/null || true printf 'scheduled_action=%s\n' "$action" - printf 'scheduled_pid=%s\n' "$!" + printf 'scheduled_pid=%s\n' "$scheduled_pid" printf 'scheduled_after_seconds=%s\n' "$ACTION_DELAY_SECONDS" printf 'scheduled_log=%s\n' "$job_log" }