Skip to main content

tauri_plugin_py_runtime/runtime/
supervisor.rs

1//! Python 进程的监督者:启动、握手、读循环、崩溃重启与关闭。
2//!
3//! 架构位置:前端 -> Tauri 命令 -> Bridge -> Writer/FrameCodec -> Python 进程;
4//! [`Supervisor`] 负责进程生命周期,持有唯一的 [`Bridge`],并在进程就绪后对外提供
5//! [`Supervisor::call`]、[`Supervisor::cancel`] 与 [`Supervisor::emit`]。
6//!
7//! 状态流转(见 [`Status`]):
8//!
9//! - `Starting` -> `Running`:Python 发送协议版本匹配的 `hello`,Rust 回复 `ready`;
10//! - `Starting` -> `Failed`:进程无法启动、握手失败或超时;
11//! - `Running` -> `Crashed`:进程意外退出且未开启 `restart_on_crash`;
12//! - `Running` -> `Starting`:意外退出且开启了 `restart_on_crash`,按指数退避重启;
13//! - `Starting` / `Running` -> `Stopped`:调用 [`Supervisor::shutdown`]。
14//!
15//! 不变量:[`Writer`] 仅在握手完成后发布、在进程结束或关闭时撤下;进程结束时所有进行中的调用
16//! 都会被 [`Bridge::fail_all`] 终止,不会无限挂起。
17
18use std::{
19  sync::{Arc, Mutex},
20  time::{Duration, Instant},
21};
22
23use futures_util::StreamExt;
24use serde::Serialize;
25use tokio::sync::watch;
26use tokio_util::{codec::FramedRead, sync::CancellationToken};
27
28use crate::{
29  bridge::{
30    protocol::{AppInfo, FrameCodec, PROTOCOL_VERSION},
31    Bridge, CallOptions, ChannelSink, EventSink, Frame, Message, Payload, Writer,
32  },
33  error::{codes, Error, Result},
34  runtime::process::{self, ProcessConfig, Spawned},
35};
36
37/// 首次重启前的等待时间,之后每次连续崩溃翻倍。
38const RESTART_DELAY: Duration = Duration::from_secs(1);
39/// 重启等待时间上限。
40const MAX_RESTART_DELAY: Duration = Duration::from_secs(30);
41/// 进程运行超过该时长后再崩溃,退避重新从 [`RESTART_DELAY`] 开始。
42const STABLE_UPTIME: Duration = Duration::from_secs(30);
43/// 在 `shutdown_timeout` 之外额外给 Python 的退出宽限时间,覆盖其关闭钩子之后的清理与进程退出。
44const SHUTDOWN_GRACE: Duration = Duration::from_secs(1);
45
46/// 单次进程运行的结束方式。
47enum Exit {
48  /// 因关闭请求而停止,不再重启。
49  Stopped,
50  /// 启动或握手失败,不再重启。
51  Failed,
52  /// 意外退出,可能触发重启。
53  Crashed { code: Option<i32>, uptime: Duration },
54}
55
56/// Python 运行时的状态,序列化为小写字符串返回给前端(`pyStatus()`)。
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, strum::IntoStaticStr, strum::AsRefStr)]
58#[serde(rename_all = "lowercase")]
59#[strum(serialize_all = "lowercase")]
60pub enum Status {
61  /// 进程启动中、等待握手,或处于崩溃重启的退避等待中;此时的调用会等待就绪。
62  Starting,
63  /// 握手完成,可以处理调用。
64  Running,
65  /// 进程意外退出且未开启自动重启。
66  Crashed,
67  /// 已通过 [`Supervisor::shutdown`] 停止。
68  Stopped,
69  /// 启动失败、握手失败或超时;失败原因会作为后续调用的错误返回。
70  Failed,
71}
72
73/// [`Supervisor`] 的完整配置,由插件 `Builder` 与 `tauri.conf.json` 合并得到。
74#[derive(Debug, Clone)]
75pub struct SupervisorConfig {
76  /// 子进程启动参数。
77  pub process: ProcessConfig,
78  /// 等待 `hello` 的时长,同时也是调用等待运行时就绪的最长时间。
79  pub handshake_timeout: Duration,
80  /// 每次调用的默认超时。
81  pub invoke_timeout: Duration,
82  /// 通过 `shutdown` 帧交给 Python 的关闭预算;超过该时长再加 1 秒宽限后进程会被杀死。
83  pub shutdown_timeout: Duration,
84  /// 进程意外退出后是否重启。采用指数退避(从 1 秒开始翻倍,最多 30 秒,进程稳定运行 30 秒后
85  /// 重置)。重启后的进程若握手失败,则停止自动重启,状态变为 [`Status::Failed`]。
86  pub restart_on_crash: bool,
87  /// 单帧大小上限,同时作用于收发两个方向。
88  pub max_frame_size: usize,
89  /// 通过 `ready` 帧告知 Python 的应用名称。
90  pub app_name: String,
91  /// 通过 `ready` 帧告知 Python 的应用版本。
92  pub app_version: String,
93}
94
95/// Python 进程的监督者,负责其完整生命周期并转发调用、取消与事件。
96///
97/// 通过 [`Supervisor::new`] 创建(返回 `Arc`),[`Supervisor::start`] 启动,
98/// [`Supervisor::shutdown`] 关闭。
99pub struct Supervisor {
100  config: SupervisorConfig,
101  bridge: Bridge,
102  sink: Arc<dyn EventSink>,
103  status: watch::Sender<Status>,
104  /// 握手完成后为 `Some`,进程结束或开始关闭时被取走。
105  writer: Mutex<Option<Writer>>,
106  /// 最近一次进入 `Failed` 的原因,供后续调用作为错误返回。
107  last_error: Mutex<Option<Error>>,
108  /// 已请求关闭:通知 Python 退出,并中止启动过程与重启退避。
109  stop: CancellationToken,
110  /// 关闭预算已耗尽:杀死仍在运行的子进程。
111  kill: CancellationToken,
112}
113
114impl Supervisor {
115  /// 创建处于 [`Status::Starting`] 的监督者,不会启动进程;需随后调用 [`Supervisor::start`]。
116  pub fn new(config: SupervisorConfig, sink: Arc<dyn EventSink>) -> Arc<Self> {
117    let (status, _) = watch::channel(Status::Starting);
118    Arc::new(Self {
119      config,
120      bridge: Bridge::new(),
121      sink,
122      status,
123      writer: Mutex::new(None),
124      last_error: Mutex::new(None),
125      stop: CancellationToken::new(),
126      kill: CancellationToken::new(),
127    })
128  }
129
130  /// 返回当前状态。
131  pub fn status(&self) -> Status {
132    *self.status.borrow()
133  }
134
135  /// 在后台启动 Python 进程并开始监督,立即返回。
136  ///
137  /// 只应调用一次:每次调用都会启动一个独立的监督循环;[`Supervisor::shutdown`] 之后再调用也
138  /// 无法重新启动。
139  pub fn start(self: &Arc<Self>) {
140    *self.last_error.lock().unwrap() = None;
141    self.status.send_replace(Status::Starting);
142    let this = self.clone();
143    // 插件 setup 运行在主线程,通常不处于 tokio 运行时上下文中,直接 tokio::spawn 会 panic,
144    // 因此使用 Tauri 的全局运行时。
145    tauri::async_runtime::spawn(async move { this.run().await });
146  }
147
148  /// 监督循环:运行进程,并在意外退出时按退避策略重启。
149  async fn run(self: Arc<Self>) {
150    let mut crashes = 0u32;
151    loop {
152      let (code, uptime) = match self.run_once().await {
153        Exit::Stopped | Exit::Failed => return,
154        Exit::Crashed { code, uptime } => (code, uptime),
155      };
156      log::error!(target: "py-runtime", "python runtime exited unexpectedly (code {code:?})");
157      self.bridge.fail_all(codes::RUNTIME_EXITED, "python runtime exited");
158
159      if !self.config.restart_on_crash {
160        self.status.send_replace(Status::Crashed);
161        self.sink.exited(code);
162        return;
163      }
164
165      self.sink.exited(code);
166      self.status.send_replace(Status::Starting);
167      // 退避期间状态保持 `Starting`,新调用会等待重启完成(最多 `handshake_timeout`)。
168      crashes = if uptime >= STABLE_UPTIME { 1 } else { crashes + 1 };
169      let delay = RESTART_DELAY
170        .saturating_mul(1 << (crashes - 1).min(5))
171        .min(MAX_RESTART_DELAY);
172      log::info!(target: "py-runtime", "restarting python runtime in {delay:?}");
173      tokio::select! {
174        _ = tokio::time::sleep(delay) => {}
175        _ = self.stop.cancelled() => {}
176      }
177      if self.stop.is_cancelled() {
178        self.status.send_replace(Status::Stopped);
179        return;
180      }
181    }
182  }
183
184  /// 运行一次 Python 进程:启动、握手、读循环,直到进程结束。
185  async fn run_once(&self) -> Exit {
186    let Spawned { mut child, stdin, stdout, stderr } = match process::spawn(&self.config.process) {
187      Ok(spawned) => spawned,
188      Err(e) => {
189        self.fail(codes::RUNTIME_UNAVAILABLE, &format!("failed to start python: {e}"));
190        return Exit::Failed;
191      }
192    };
193    process::spawn_stderr_logger(stderr);
194    let writer = Writer::spawn(stdin, self.config.max_frame_size);
195    let mut frames = FramedRead::new(stdout, FrameCodec::new(self.config.max_frame_size));
196
197    let handshake = tokio::select! {
198      result = tokio::time::timeout(self.config.handshake_timeout, frames.next()) => result,
199      _ = self.stop.cancelled() => return self.stop_child(child).await,
200    };
201    let failure = match handshake {
202      Ok(Some(Ok(Ok(Frame { message: Message::Hello { protocol, sdk, commands }, .. })))) => {
203        if protocol == PROTOCOL_VERSION {
204          log::debug!(target: "py-runtime", "python sdk {sdk} registered {} command(s)", commands.len());
205          None
206        } else {
207          let message = format!("python sdk speaks protocol {protocol}, expected {PROTOCOL_VERSION}");
208          Some((codes::PROTOCOL_MISMATCH, message))
209        }
210      }
211      Ok(Some(Ok(Ok(frame)))) => {
212        Some((codes::RUNTIME_UNAVAILABLE, format!("expected hello, got {}", <&str>::from(&frame.message))))
213      }
214      // 首帧无法解码,最可能是对端 SDK 使用了其他协议版本(帧格式不同)。
215      Ok(Some(Ok(Err(e)))) => Some((codes::PROTOCOL_MISMATCH, format!("could not decode handshake: {e}"))),
216      Ok(Some(Err(e))) => Some((codes::RUNTIME_UNAVAILABLE, format!("handshake failed: {e}"))),
217      Ok(None) => Some((codes::RUNTIME_UNAVAILABLE, "python exited before handshake".into())),
218      Err(_) => Some((codes::RUNTIME_UNAVAILABLE, "handshake timed out".into())),
219    };
220    if let Some((code, message)) = failure {
221      let _ = child.kill().await;
222      self.fail(code, &message);
223      return Exit::Failed;
224    }
225
226    let app = AppInfo { name: self.config.app_name.clone(), version: self.config.app_version.clone() };
227    let _ = writer.send(Frame::new(Message::Ready { protocol: PROTOCOL_VERSION, app }));
228    *self.writer.lock().unwrap() = Some(writer);
229    // 必须在发布 writer 之后检查:并发的 shutdown 要么已取走 writer(并发送了 `shutdown` 帧),
230    // 要么在此之前已取消 `stop`,两种情况都不会遗漏。
231    if self.stop.is_cancelled() {
232      *self.writer.lock().unwrap() = None;
233      return self.stop_child(child).await;
234    }
235    self.status.send_replace(Status::Running);
236    let running_since = Instant::now();
237    log::info!(target: "py-runtime", "python runtime is running");
238
239    loop {
240      tokio::select! {
241        next = frames.next() => match next {
242          Some(Ok(Ok(frame))) => self.bridge.handle(frame, self.sink.as_ref()),
243          Some(Ok(Err(e))) => log::warn!(target: "py-runtime", "dropped frame from python: {e}"),
244          // 读错误(含超限帧)后流已无法对齐,只能结束进程,按崩溃处理。
245          Some(Err(e)) => {
246            log::error!(target: "py-runtime", "failed to read from python: {e}");
247            let _ = child.kill().await;
248            break;
249          }
250          None => break,
251        },
252        _ = self.kill.cancelled() => break,
253      }
254    }
255
256    *self.writer.lock().unwrap() = None;
257    let exit = tokio::select! {
258      status = child.wait() => status.ok(),
259      _ = self.kill.cancelled() => {
260        let _ = child.kill().await;
261        child.wait().await.ok()
262      }
263    };
264    let code = exit.and_then(|s| s.code());
265
266    if self.stop.is_cancelled() {
267      self.bridge.fail_all(codes::RUNTIME_UNAVAILABLE, "python runtime stopped");
268      self.status.send_replace(Status::Stopped);
269      log::info!(target: "py-runtime", "python runtime stopped (code {code:?})");
270      return Exit::Stopped;
271    }
272    Exit::Crashed { code, uptime: running_since.elapsed() }
273  }
274
275  /// 启动阶段收到关闭请求:Python 尚未就绪、无法处理 `shutdown` 帧,直接杀死进程。
276  async fn stop_child(&self, mut child: tokio::process::Child) -> Exit {
277    let _ = child.kill().await;
278    let _ = child.wait().await;
279    self.bridge.fail_all(codes::RUNTIME_UNAVAILABLE, "python runtime stopped");
280    self.status.send_replace(Status::Stopped);
281    log::info!(target: "py-runtime", "python runtime stopped during startup");
282    Exit::Stopped
283  }
284
285  /// 进入 [`Status::Failed`],记录原因并以同一错误终止所有进行中的调用。
286  fn fail(&self, code: &str, message: &str) {
287    log::error!(target: "py-runtime", "{message}");
288    *self.writer.lock().unwrap() = None;
289    *self.last_error.lock().unwrap() = Some(Error::new(code, message));
290    self.bridge.fail_all(code, message);
291    self.status.send_replace(Status::Failed);
292  }
293
294  /// 等待运行时进入 `Running` 并返回其 [`Writer`],最多等待 `handshake_timeout`。
295  ///
296  /// 状态离开 `Starting` 却不是 `Running` 时立即失败;`Failed` 时返回记录的失败原因,
297  /// 其余情况返回 `runtime_unavailable`。
298  async fn wait_running(&self) -> Result<Writer> {
299    let mut rx = self.status.subscribe();
300    let ready = async {
301      loop {
302        let status = *rx.borrow_and_update();
303        if status == Status::Running {
304          if let Some(writer) = self.writer.lock().unwrap().clone() {
305            return Some(writer);
306          }
307        } else if status != Status::Starting {
308          return None;
309        }
310        if rx.changed().await.is_err() {
311          return None;
312        }
313      }
314    };
315    let writer = tokio::time::timeout(self.config.handshake_timeout, ready).await.ok().flatten();
316    writer.ok_or_else(|| {
317      let status = self.status();
318      if status == Status::Failed {
319        if let Some(error) = self.last_error.lock().unwrap().clone() {
320          return error;
321        }
322      }
323      Error::new(codes::RUNTIME_UNAVAILABLE, format!("python runtime is {}", status.as_ref()))
324    })
325  }
326
327  /// 调用 Python 命令 `cmd`,必要时先等待运行时就绪。
328  ///
329  /// 运行时处于 `Starting` 时最多等待 `handshake_timeout`;调用本身的超时规则见 [`Bridge::call`]。
330  ///
331  /// # Errors
332  ///
333  /// - `runtime_unavailable`:运行时未就绪、已停止或已崩溃,或等待就绪超时;
334  /// - `protocol_mismatch` 等:运行时处于 [`Status::Failed`] 时返回导致失败的原始错误;
335  /// - `runtime_exited`:调用进行中 Python 进程退出;
336  /// - 其余错误(`invalid_args`、`timeout`、`cancelled`、`frame_too_large`、Python 返回的错误等)
337  ///   见 [`Bridge::call`]。
338  pub async fn call(
339    &self,
340    cmd: &str,
341    args: Payload,
342    channels: Vec<(String, ChannelSink)>,
343    opts: CallOptions,
344  ) -> Result<Payload> {
345    let writer = self.wait_running().await?;
346    self.bridge.call(&writer, cmd, args, channels, opts, self.config.invoke_timeout).await
347  }
348
349  /// 取消 `window` 窗口中客户端 id 为 `client_id` 的调用,返回是否找到该调用。
350  ///
351  /// 运行时未运行时仍会让等待者以 `cancelled` 失败,只是不再通知 Python。详见 [`Bridge::cancel`]。
352  pub fn cancel(&self, window: &str, client_id: &str) -> bool {
353    let writer = self.writer.lock().unwrap().clone();
354    self.bridge.cancel(writer.as_ref(), window, client_id)
355  }
356
357  /// 向 Python 发送事件,由其 `@app.listen(event)` 处理;`window` 为发送方 webview 标签。
358  ///
359  /// 不等待运行时就绪,也不等待 Python 处理。
360  ///
361  /// # Errors
362  ///
363  /// - `runtime_unavailable`:运行时当前未运行;
364  /// - `frame_too_large`:事件帧超过大小上限。
365  pub fn emit(&self, event: &str, payload: Payload, window: Option<&str>) -> Result<()> {
366    let writer = self
367      .writer
368      .lock()
369      .unwrap()
370      .clone()
371      .ok_or_else(|| Error::new(codes::RUNTIME_UNAVAILABLE, "python runtime is not running"))?;
372    let (payload, body) = payload.split();
373    let message = Message::Event { event: event.into(), target: None, window: window.map(Into::into), payload };
374    writer.send(Frame { message, body })
375  }
376
377  /// 关闭运行时并等待进程退出,不可逆。
378  ///
379  /// 向 Python 发送带 `timeoutMs` 的 `shutdown` 帧,由其在该预算内取消进行中的调用并执行
380  /// `shutdown` 钩子;若在 `shutdown_timeout` 加 1 秒宽限后仍未退出,则强制杀死进程。启动阶段或
381  /// 重启退避中调用时会直接中止启动;已处于 `Crashed` / `Failed` / `Stopped` 时立即返回。
382  pub async fn shutdown(&self) {
383    self.stop.cancel();
384    // kill 只是兜底:Python 自身在 `timeoutMs` 内会放弃未响应的处理器并退出,宽限时间用于覆盖
385    // 其退出过程和管道传递的延迟,避免误杀正在正常退出的进程。
386    let budget = self.config.shutdown_timeout + SHUTDOWN_GRACE;
387    let writer = self.writer.lock().unwrap().take();
388    match writer {
389      Some(writer) => {
390        let timeout_ms = self.config.shutdown_timeout.as_millis() as u64;
391        let _ = writer.send(Frame::new(Message::Shutdown { timeout_ms }));
392      }
393      None if !matches!(self.status(), Status::Starting | Status::Running) => return,
394      None => {}
395    }
396
397    if !self.wait_stopped(budget).await {
398      log::warn!(target: "py-runtime", "python runtime did not exit in time, killing it");
399      self.kill.cancel();
400      self.wait_stopped(Duration::from_secs(1)).await;
401    }
402  }
403
404  /// 等待状态进入终态(`Stopped` / `Crashed` / `Failed`),返回是否在 `timeout` 内达到。
405  async fn wait_stopped(&self, timeout: Duration) -> bool {
406    let mut rx = self.status.subscribe();
407    let stopped = async {
408      loop {
409        if matches!(*rx.borrow_and_update(), Status::Stopped | Status::Crashed | Status::Failed) {
410          return;
411        }
412        if rx.changed().await.is_err() {
413          return;
414        }
415      }
416    };
417    tokio::time::timeout(timeout, stopped).await.is_ok()
418  }
419}
420