tauri-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tauri_runtime/__init__.py +37 -0
- tauri_runtime/app.py +587 -0
- tauri_runtime/channel.py +79 -0
- tauri_runtime/cli/__init__.py +75 -0
- tauri_runtime/cli/bundle.py +127 -0
- tauri_runtime/cli/scaffold.py +310 -0
- tauri_runtime/cli/templates.py +92 -0
- tauri_runtime/command.py +206 -0
- tauri_runtime/event.py +35 -0
- tauri_runtime/protocol.py +483 -0
- tauri_runtime/py.typed +0 -0
- tauri_runtime/router.py +147 -0
- tauri_runtime-0.1.0.dist-info/METADATA +95 -0
- tauri_runtime-0.1.0.dist-info/RECORD +18 -0
- tauri_runtime-0.1.0.dist-info/WHEEL +4 -0
- tauri_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- tauri_runtime-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- tauri_runtime-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tauri 2 插件 `py-runtime` 的 Python SDK。
|
|
2
|
+
|
|
3
|
+
Python 进程作为 Tauri 应用的 sidecar 运行,通过 stdio 上的长度前缀帧协议与 Rust 宿主通信。
|
|
4
|
+
本包对外导出编写后端所需的全部公共 API:
|
|
5
|
+
|
|
6
|
+
- `App`:运行时入口,负责注册命令、生命周期钩子与事件监听,并驱动协议循环。
|
|
7
|
+
- `Command`:单次调用的上下文(调用 id、命令名、来源窗口、是否已取消)。
|
|
8
|
+
- `Channel`:从 Python 向前端 `Channel` 推送数据的流。
|
|
9
|
+
- `Event`:前端通过 `pyEmit` 发来的事件。
|
|
10
|
+
- `Raw`:二进制载荷,经帧体传输而非内联 JSON。
|
|
11
|
+
- `Router`:按模块分组注册命令、事件监听与钩子,由 `App.include()` 挂载。
|
|
12
|
+
- `TauriError`:带自定义错误码的结构化错误。
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
```python
|
|
16
|
+
from tauri_runtime import App
|
|
17
|
+
|
|
18
|
+
app = App()
|
|
19
|
+
|
|
20
|
+
@app.command
|
|
21
|
+
async def hello(name: str) -> str:
|
|
22
|
+
return f"Hello {name}"
|
|
23
|
+
|
|
24
|
+
app.run()
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .app import App
|
|
29
|
+
from .channel import Channel
|
|
30
|
+
from .command import Command, TauriError
|
|
31
|
+
from .event import Event
|
|
32
|
+
from .protocol import PROTOCOL_VERSION, SDK_VERSION, Raw
|
|
33
|
+
from .router import Router
|
|
34
|
+
|
|
35
|
+
__version__ = SDK_VERSION
|
|
36
|
+
|
|
37
|
+
__all__ = ["PROTOCOL_VERSION", "SDK_VERSION", "App", "Channel", "Command", "Event", "Raw", "Router", "TauriError"]
|
tauri_runtime/app.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""运行时核心:`App` 负责注册命令、钩子与事件监听,并驱动协议主循环。
|
|
2
|
+
|
|
3
|
+
主循环在 asyncio 事件循环上运行:专用线程阻塞读取帧,按消息类型分发为调用任务、取消、
|
|
4
|
+
事件投递或生命周期钩子;所有输出经线程安全的 `Writer` 写回宿主。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import logging
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import traceback
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
+
from typing import Any, BinaryIO, NoReturn
|
|
21
|
+
|
|
22
|
+
from pydantic.alias_generators import to_snake
|
|
23
|
+
|
|
24
|
+
from . import protocol as wire
|
|
25
|
+
from .channel import Channel
|
|
26
|
+
from .command import Command, TauriError, bind_arguments, build_handler
|
|
27
|
+
from .event import Event, EventBus
|
|
28
|
+
from .protocol import (
|
|
29
|
+
PROTOCOL_VERSION,
|
|
30
|
+
Frame,
|
|
31
|
+
FrameTooLarge,
|
|
32
|
+
MalformedFrame,
|
|
33
|
+
ProtocolError,
|
|
34
|
+
Raw,
|
|
35
|
+
Writer,
|
|
36
|
+
join_payload,
|
|
37
|
+
read_frame,
|
|
38
|
+
split_payload,
|
|
39
|
+
take_over_stdio,
|
|
40
|
+
)
|
|
41
|
+
from .router import LIFECYCLE, CommandTable, Router, check_hook
|
|
42
|
+
|
|
43
|
+
RESERVED_EVENTS = ("exited",)
|
|
44
|
+
DEFAULT_SHUTDOWN_TIMEOUT_S = 3.0
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def shutdown_budget(value: Any) -> float:
|
|
50
|
+
"""将 `Shutdown.timeoutMs` 换算为关闭预算(秒)。
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
value: 原始 `timeoutMs`;非数值、布尔值、非有限值或负数均视为非法。
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
关闭预算(秒);非法值回退为 `DEFAULT_SHUTDOWN_TIMEOUT_S`(3 秒)。
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
59
|
+
return DEFAULT_SHUTDOWN_TIMEOUT_S
|
|
60
|
+
if not math.isfinite(value) or value < 0:
|
|
61
|
+
return DEFAULT_SHUTDOWN_TIMEOUT_S
|
|
62
|
+
return value / 1000.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _exit_code(exc: SystemExit) -> int:
|
|
66
|
+
"""按解释器的约定将 `SystemExit` 转为退出码:`None` 为 0,整数原样,其他值打印到 stderr 后为 1。"""
|
|
67
|
+
if exc.code is None:
|
|
68
|
+
return 0
|
|
69
|
+
if isinstance(exc.code, int):
|
|
70
|
+
return exc.code
|
|
71
|
+
logger.error("[ERROR] %s", exc.code)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class App:
|
|
76
|
+
"""Tauri Python sidecar 运行时。
|
|
77
|
+
|
|
78
|
+
一个进程通常只创建一个 `App`:用装饰器注册命令、生命周期钩子和事件监听,最后调用
|
|
79
|
+
`run()` 接管进程并进入协议主循环。
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
```python
|
|
83
|
+
from tauri_runtime import App, Channel, Event
|
|
84
|
+
|
|
85
|
+
app = App()
|
|
86
|
+
|
|
87
|
+
@app.command
|
|
88
|
+
async def hello(name: str) -> str:
|
|
89
|
+
return f"Hello {name}"
|
|
90
|
+
|
|
91
|
+
@app.on("ready")
|
|
92
|
+
async def on_ready():
|
|
93
|
+
print("[INFO] backend ready")
|
|
94
|
+
|
|
95
|
+
@app.listen("ping")
|
|
96
|
+
async def on_ping(event: Event):
|
|
97
|
+
await app.emit("pong", event.payload, target=event.window)
|
|
98
|
+
|
|
99
|
+
app.run()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
生命周期钩子(`@app.on(...)`):
|
|
103
|
+
- `startup`:握手之前运行;抛出异常会终止运行时(退出码 1),不会发送 `hello`。
|
|
104
|
+
- `ready`:收到 Rust 侧的 `ready` 确认后运行;异常仅记录日志。
|
|
105
|
+
- `shutdown`:收到 `shutdown` 或输入流结束后运行,与取消在途调用共享同一关闭预算,
|
|
106
|
+
超时即放弃;异常仅记录日志。
|
|
107
|
+
|
|
108
|
+
同步与异步处理器:
|
|
109
|
+
`async def` 命令、钩子和监听器直接在事件循环上运行;普通 `def` 在线程池中运行
|
|
110
|
+
(默认 `min(32, cpu_count + 4)` 个工作线程),不会阻塞事件循环。
|
|
111
|
+
|
|
112
|
+
取消语义:
|
|
113
|
+
前端中止或调用超时时,Rust 发送 `cancel`,运行时将对应 `Command.cancelled` 置为
|
|
114
|
+
`True` 并取消其 asyncio 任务。异步处理器在下一个 `await` 处收到 `CancelledError`;
|
|
115
|
+
同步处理器无法被中断(Python 不能打断任意线程),会在工作线程中继续运行直至返回,
|
|
116
|
+
其结果被丢弃,可轮询 `Command.cancelled` 主动提前退出。已取消的调用不会再发送任何响应。
|
|
117
|
+
|
|
118
|
+
参数绑定:
|
|
119
|
+
- 请求中 JSON 参数对象的 camelCase 键转为 snake_case 后与形参名匹配。
|
|
120
|
+
- 以下注解的参数由运行时注入,而非取自 JSON:`Command`(调用上下文)、`App`
|
|
121
|
+
(应用实例)、`Raw`(二进制请求体)、`Channel[T]`(同名的前端通道)。
|
|
122
|
+
- 带注解的普通参数经 pydantic 校验与转换;校验失败或缺少必填参数时以 `invalid_args`
|
|
123
|
+
失败,校验错误放在 `data` 中。
|
|
124
|
+
- 返回值可以是任意可被 pydantic 序列化为 JSON 的值;返回 `Raw` / `bytes` 则以二进制发送。
|
|
125
|
+
抛出 `TauriError` 返回自定义错误码,其他异常转为 `internal`(设置环境变量
|
|
126
|
+
`TAURI_RUNTIME_DEBUG=1` 时 `data` 中附带 traceback)。
|
|
127
|
+
|
|
128
|
+
stdio 接管:
|
|
129
|
+
默认(`stdio=True`)构造时即接管 stdio:原 stdin/stdout 专供协议使用,`sys.stdout`
|
|
130
|
+
改指向 stderr,因此 `print()` 是安全的。stderr 由宿主按行转发到 Rust `log`(target 为
|
|
131
|
+
`py-runtime`),行首的 `[DEBUG]` / `[INFO]` / `[WARN]` / `[ERROR]` 前缀决定日志级别并被
|
|
132
|
+
去除,无前缀的行按 `INFO` 记录。
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
stdio: 是否在构造时接管进程 stdio。设为 `False` 时须自行调用 `serve()` 并提供流(如测试)。
|
|
136
|
+
max_workers: 同步处理器线程池大小;默认 `min(32, cpu_count + 4)`。
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, *, stdio: bool = True, max_workers: int | None = None) -> None:
|
|
140
|
+
self._router = CommandTable()
|
|
141
|
+
self._events = EventBus()
|
|
142
|
+
self._hooks: dict[str, list[Callable[..., Any]]] = {name: [] for name in LIFECYCLE}
|
|
143
|
+
self._max_workers = max_workers or min(32, (os.cpu_count() or 1) + 4)
|
|
144
|
+
self._stdio = take_over_stdio() if stdio else None
|
|
145
|
+
self._writer: Writer | None = None
|
|
146
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
147
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
148
|
+
self._calls: dict[int, tuple[asyncio.Future[Any], Command]] = {}
|
|
149
|
+
self._background: set[asyncio.Future[Any]] = set()
|
|
150
|
+
self._protocol_error = False
|
|
151
|
+
|
|
152
|
+
# 注册 ---------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def command(self, name_or_func: Any = None) -> Any:
|
|
155
|
+
"""注册命令处理器的装饰器,可直接使用或传入自定义命令名。
|
|
156
|
+
|
|
157
|
+
命令须在 `run()` / `serve()` 之前注册,命令列表随 `hello` 握手发送给宿主。
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
name_or_func: 被装饰的函数(`@app.command`),或自定义命令名
|
|
161
|
+
(`@app.command("name")`);省略时使用函数名。
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
直接装饰时返回原函数;传入命令名时返回装饰器。
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: 命令名已注册。
|
|
168
|
+
|
|
169
|
+
Example:
|
|
170
|
+
```python
|
|
171
|
+
@app.command
|
|
172
|
+
async def hello(name: str) -> str:
|
|
173
|
+
return f"Hello {name}"
|
|
174
|
+
|
|
175
|
+
@app.command("get_version")
|
|
176
|
+
def version() -> str:
|
|
177
|
+
return "1.0.0"
|
|
178
|
+
```
|
|
179
|
+
"""
|
|
180
|
+
if callable(name_or_func):
|
|
181
|
+
self.add_command(name_or_func)
|
|
182
|
+
return name_or_func
|
|
183
|
+
|
|
184
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
185
|
+
self.add_command(func, name_or_func)
|
|
186
|
+
return func
|
|
187
|
+
|
|
188
|
+
return decorator
|
|
189
|
+
|
|
190
|
+
def add_command(self, func: Callable[..., Any], name: str | None = None) -> None:
|
|
191
|
+
"""以命令式方式注册命令,等价于 `@app.command` / `@app.command(name)`。
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
func: 命令处理器,同步或异步函数。
|
|
195
|
+
name: 命令名;省略时使用函数名。
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValueError: 命令名已注册。
|
|
199
|
+
TypeError: 处理器签名不受支持(如参数注解无法被 pydantic 处理)。
|
|
200
|
+
"""
|
|
201
|
+
self._router.add(build_handler(name or func.__name__, func, App))
|
|
202
|
+
|
|
203
|
+
def add_commands(self, *funcs: Callable[..., Any]) -> None:
|
|
204
|
+
"""批量注册命令,命令名均取函数名。
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
```python
|
|
208
|
+
from . import files
|
|
209
|
+
|
|
210
|
+
app.add_commands(files.read, files.write, files.remove)
|
|
211
|
+
```
|
|
212
|
+
"""
|
|
213
|
+
for func in funcs:
|
|
214
|
+
self.add_command(func)
|
|
215
|
+
|
|
216
|
+
def add_listener(self, event: str, func: Callable[..., Any]) -> None:
|
|
217
|
+
"""以命令式方式注册事件监听器,等价于 `@app.listen(event)`。"""
|
|
218
|
+
self._events.add(event, func)
|
|
219
|
+
|
|
220
|
+
def add_hook(self, hook: str, func: Callable[..., Any]) -> None:
|
|
221
|
+
"""以命令式方式注册生命周期钩子,等价于 `@app.on(hook)`。
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: 未知的钩子名。
|
|
225
|
+
"""
|
|
226
|
+
check_hook(hook)
|
|
227
|
+
self._hooks[hook].append(func)
|
|
228
|
+
|
|
229
|
+
def include(self, router: Router) -> None:
|
|
230
|
+
"""挂载一个 `Router`,注册其中的全部命令、事件监听器与生命周期钩子。
|
|
231
|
+
|
|
232
|
+
命令名已带上 `Router` 的前缀(如 `"user.login"`);挂载时取 `Router` 当前内容的
|
|
233
|
+
快照,之后再向其添加的内容不会生效。须在 `run()` / `serve()` 之前调用。
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: 命令名与已注册的命令重复。
|
|
237
|
+
TypeError: 某个处理器签名不受支持。
|
|
238
|
+
"""
|
|
239
|
+
for name, func in router.commands:
|
|
240
|
+
self.add_command(func, name)
|
|
241
|
+
for event, func in router.listeners:
|
|
242
|
+
self.add_listener(event, func)
|
|
243
|
+
for hook, func in router.hooks:
|
|
244
|
+
self.add_hook(hook, func)
|
|
245
|
+
|
|
246
|
+
def on(self, hook: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
247
|
+
"""注册生命周期钩子的装饰器。
|
|
248
|
+
|
|
249
|
+
钩子不接收参数,可为同步或异步函数;同一钩子的多个函数按注册顺序依次运行。
|
|
250
|
+
各钩子的触发时机与异常处理见类文档。
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
hook: `"startup"`、`"ready"` 或 `"shutdown"`。
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
装饰器,原样返回被装饰的函数。
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
ValueError: 未知的钩子名。
|
|
260
|
+
"""
|
|
261
|
+
check_hook(hook)
|
|
262
|
+
|
|
263
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
264
|
+
self.add_hook(hook, func)
|
|
265
|
+
return func
|
|
266
|
+
|
|
267
|
+
return decorator
|
|
268
|
+
|
|
269
|
+
def listen(self, event: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
270
|
+
"""注册事件监听器的装饰器,接收前端通过 `pyEmit` 发送的事件。
|
|
271
|
+
|
|
272
|
+
监听器接收唯一参数 `Event`,可为同步或异步函数。每次事件为每个监听器启动独立任务,
|
|
273
|
+
监听器的异常仅记录日志。
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
event: 事件名。
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
装饰器,原样返回被装饰的函数。
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
283
|
+
self.add_listener(event, func)
|
|
284
|
+
return func
|
|
285
|
+
|
|
286
|
+
return decorator
|
|
287
|
+
|
|
288
|
+
async def emit(self, event: str, payload: Any = None, *, target: str | None = None) -> None:
|
|
289
|
+
"""向前端发送事件。
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
event: 事件名。`exited` 保留给运行时自身的 `py://exited` 事件。
|
|
293
|
+
payload: 事件载荷:可被 pydantic 序列化为 JSON 的值,或 `Raw` / `bytes`。
|
|
294
|
+
target: 目标 webview 标签;为 `None` 时广播给所有 webview。
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
ValueError: 事件名为保留名,或载荷无法编码为合法 JSON(如包含 `NaN` / `±Infinity`)。
|
|
298
|
+
RuntimeError: 应用尚未运行。
|
|
299
|
+
FrameTooLarge: 编码后超过帧大小上限。
|
|
300
|
+
"""
|
|
301
|
+
if event in RESERVED_EVENTS:
|
|
302
|
+
raise ValueError(f"event name '{event}' is reserved by the runtime")
|
|
303
|
+
if self._writer is None:
|
|
304
|
+
raise RuntimeError("app is not running")
|
|
305
|
+
fields, body = split_payload(payload)
|
|
306
|
+
self._writer.write(wire.Event(event=event, target=target, **fields), body)
|
|
307
|
+
|
|
308
|
+
# 运行 ---------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def run(self) -> NoReturn:
|
|
311
|
+
"""在已接管的 stdio 上运行协议主循环,结束后终止进程,永不返回。
|
|
312
|
+
|
|
313
|
+
退出码:正常结束为 0;发生协议错误(流损坏、读错误)为 2;`startup` 钩子失败或有
|
|
314
|
+
其他异常逃逸为 1;处理器中抛出的 `SystemExit`(如 `sys.exit(n)`)使用其退出码。
|
|
315
|
+
退出前会关闭 `logging` 并 flush stderr 与协议输出流。
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
RuntimeError: 以 `App(stdio=False)` 构造,此时应改用 `serve()`。
|
|
319
|
+
"""
|
|
320
|
+
if self._stdio is None:
|
|
321
|
+
raise RuntimeError("App(stdio=False) must be driven with serve()")
|
|
322
|
+
reader, writer = self._stdio
|
|
323
|
+
code = 1
|
|
324
|
+
try:
|
|
325
|
+
code = self._drive(reader, writer)
|
|
326
|
+
except SystemExit as e:
|
|
327
|
+
code = _exit_code(e)
|
|
328
|
+
except BaseException:
|
|
329
|
+
logger.exception("[ERROR] runtime crashed")
|
|
330
|
+
finally:
|
|
331
|
+
logging.shutdown()
|
|
332
|
+
for stream in (sys.stderr, writer):
|
|
333
|
+
# 管道已断开(OSError)或流已关闭(ValueError)时无处可报,直接忽略。
|
|
334
|
+
with contextlib.suppress(OSError, ValueError):
|
|
335
|
+
stream.flush()
|
|
336
|
+
# 用 os._exit 跳过解释器退出时对工作线程的 join,卡住的同步处理器不会阻塞退出。
|
|
337
|
+
os._exit(code)
|
|
338
|
+
|
|
339
|
+
def _drive(self, reader: BinaryIO, writer: BinaryIO) -> int:
|
|
340
|
+
"""在新事件循环上运行 `serve()`。
|
|
341
|
+
|
|
342
|
+
不使用 `asyncio.run`:它结束时会取消并等待所有残留任务,吞掉 `CancelledError` 的
|
|
343
|
+
处理器会让进程永远挂起。
|
|
344
|
+
"""
|
|
345
|
+
loop = asyncio.new_event_loop()
|
|
346
|
+
try:
|
|
347
|
+
return loop.run_until_complete(self.serve(reader, writer))
|
|
348
|
+
finally:
|
|
349
|
+
loop.close()
|
|
350
|
+
|
|
351
|
+
async def serve(self, reader: BinaryIO, writer: BinaryIO) -> int:
|
|
352
|
+
"""在给定的流上运行协议主循环,直到收到 `shutdown` 或输入流结束。
|
|
353
|
+
|
|
354
|
+
流程:运行 `startup` 钩子 → 发送 `hello` → 循环读取并分发帧 → 取消在途调用与后台任务
|
|
355
|
+
→ 运行 `shutdown` 钩子。取消与 `shutdown` 钩子共享同一关闭预算(来自 `timeoutMs`,
|
|
356
|
+
输入流结束时为 3 秒),超出预算的任务被放弃而非等待。
|
|
357
|
+
|
|
358
|
+
与 `run()` 不同,本方法不接管 stdio、也不退出进程,适合测试或自定义宿主。
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
reader: 协议输入流(阻塞读取)。
|
|
362
|
+
writer: 协议输出流。
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
退出码:0 为正常结束,1 为 `startup` 钩子失败,2 为发生协议错误。
|
|
366
|
+
"""
|
|
367
|
+
self._loop = asyncio.get_running_loop()
|
|
368
|
+
self._writer = Writer(writer)
|
|
369
|
+
self._executor = ThreadPoolExecutor(self._max_workers, thread_name_prefix="tauri-runtime")
|
|
370
|
+
self._protocol_error = False
|
|
371
|
+
# 读取使用独立的单线程池:与处理器线程池分开,繁忙的同步处理器不会饿死读取。
|
|
372
|
+
reader_pool = ThreadPoolExecutor(1, thread_name_prefix="tauri-runtime-reader")
|
|
373
|
+
budget = DEFAULT_SHUTDOWN_TIMEOUT_S
|
|
374
|
+
try:
|
|
375
|
+
try:
|
|
376
|
+
await self._run_hooks("startup")
|
|
377
|
+
except Exception:
|
|
378
|
+
logger.exception("[ERROR] startup hook failed")
|
|
379
|
+
return 1
|
|
380
|
+
self._writer.write(wire.Hello(protocol=PROTOCOL_VERSION, commands=self._router.names()))
|
|
381
|
+
while (frame := await self._next_frame(reader_pool, reader)) is not None:
|
|
382
|
+
if isinstance(frame.message, wire.Shutdown):
|
|
383
|
+
budget = shutdown_budget(frame.message.timeout_ms)
|
|
384
|
+
break
|
|
385
|
+
try:
|
|
386
|
+
self._dispatch(frame)
|
|
387
|
+
except Exception:
|
|
388
|
+
logger.warning("[WARN] dropped %s frame", frame.message.type, exc_info=True)
|
|
389
|
+
deadline = self._loop.time() + budget
|
|
390
|
+
await self._stop_tasks(budget)
|
|
391
|
+
await self._run_hooks_logged("shutdown", timeout=max(0.0, deadline - self._loop.time()))
|
|
392
|
+
return 2 if self._protocol_error else 0
|
|
393
|
+
finally:
|
|
394
|
+
# 阻塞中的读取和卡住的处理器都不能拖延退出,因此两个线程池都不 join。
|
|
395
|
+
reader_pool.shutdown(wait=False, cancel_futures=True)
|
|
396
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
397
|
+
self._writer = None
|
|
398
|
+
|
|
399
|
+
async def _next_frame(self, pool: ThreadPoolExecutor, reader: BinaryIO) -> Frame | None:
|
|
400
|
+
"""读取下一个可解码的帧(跳过无法解码的帧);流结束或损坏时返回 `None`。"""
|
|
401
|
+
assert self._loop is not None
|
|
402
|
+
while True:
|
|
403
|
+
try:
|
|
404
|
+
return await self._loop.run_in_executor(pool, read_frame, reader)
|
|
405
|
+
except MalformedFrame as e:
|
|
406
|
+
logger.warning("[WARN] dropped frame: %s", e)
|
|
407
|
+
except ProtocolError as e:
|
|
408
|
+
self._protocol_error = True
|
|
409
|
+
logger.warning("[WARN] protocol error: %s", e)
|
|
410
|
+
return None
|
|
411
|
+
except OSError as e:
|
|
412
|
+
self._protocol_error = True
|
|
413
|
+
logger.warning("[WARN] read error: %s", e)
|
|
414
|
+
return None
|
|
415
|
+
except Exception:
|
|
416
|
+
logger.exception("[ERROR] frame reader crashed")
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
def _dispatch(self, frame: Frame) -> None:
|
|
420
|
+
match frame.message:
|
|
421
|
+
case wire.Ready():
|
|
422
|
+
self._spawn(self._run_hooks_logged("ready"))
|
|
423
|
+
case wire.Request() as request:
|
|
424
|
+
self._start_call(request, frame.body)
|
|
425
|
+
case wire.Cancel(id=call_id):
|
|
426
|
+
self._cancel_call(call_id)
|
|
427
|
+
case wire.Event() as event:
|
|
428
|
+
self._deliver_event(event, frame.body)
|
|
429
|
+
case other:
|
|
430
|
+
logger.warning("[WARN] unexpected frame %s", other.type)
|
|
431
|
+
|
|
432
|
+
# 辅助方法 -----------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
def _spawn(self, coro: Any) -> asyncio.Future[Any]:
|
|
435
|
+
"""启动后台任务并持有其强引用,关闭时统一取消。"""
|
|
436
|
+
task = asyncio.ensure_future(coro)
|
|
437
|
+
self._background.add(task)
|
|
438
|
+
task.add_done_callback(self._background.discard)
|
|
439
|
+
return task
|
|
440
|
+
|
|
441
|
+
async def _invoke(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
442
|
+
"""协程函数在事件循环上 await,普通函数交给处理器线程池执行。"""
|
|
443
|
+
if inspect.iscoroutinefunction(func):
|
|
444
|
+
return await func(*args, **kwargs)
|
|
445
|
+
assert self._loop is not None
|
|
446
|
+
return await self._loop.run_in_executor(self._executor, functools.partial(func, *args, **kwargs))
|
|
447
|
+
|
|
448
|
+
async def _run_hooks(self, hook: str) -> None:
|
|
449
|
+
for func in self._hooks[hook]:
|
|
450
|
+
await self._invoke(func)
|
|
451
|
+
|
|
452
|
+
async def _run_hooks_logged(self, hook: str, timeout: float | None = None) -> None:
|
|
453
|
+
"""运行钩子,异常只记录不抛出;指定 `timeout` 时超时即取消并放弃。"""
|
|
454
|
+
if timeout is None:
|
|
455
|
+
try:
|
|
456
|
+
await self._run_hooks(hook)
|
|
457
|
+
except Exception:
|
|
458
|
+
logger.exception("[ERROR] %s hook failed", hook)
|
|
459
|
+
return
|
|
460
|
+
# 用 asyncio.wait 而非 wait_for:后者超时后会等待被取消的任务结束,
|
|
461
|
+
# 吞掉 CancelledError 的钩子会借此突破关闭预算。
|
|
462
|
+
task = asyncio.ensure_future(self._run_hooks(hook))
|
|
463
|
+
done, _ = await asyncio.wait({task}, timeout=timeout)
|
|
464
|
+
if not done:
|
|
465
|
+
task.cancel()
|
|
466
|
+
logger.warning("[WARN] %s hooks timed out", hook)
|
|
467
|
+
elif not task.cancelled() and task.exception() is not None:
|
|
468
|
+
logger.error("[ERROR] %s hook failed", hook, exc_info=task.exception())
|
|
469
|
+
|
|
470
|
+
async def _stop_tasks(self, timeout: float) -> None:
|
|
471
|
+
"""取消所有在途调用与后台任务,至多等待 `timeout` 秒,忽略取消的任务被放弃。"""
|
|
472
|
+
for call_id in list(self._calls):
|
|
473
|
+
self._cancel_call(call_id)
|
|
474
|
+
pending = list(self._background)
|
|
475
|
+
for task in pending:
|
|
476
|
+
task.cancel()
|
|
477
|
+
if not pending:
|
|
478
|
+
return
|
|
479
|
+
_, still_running = await asyncio.wait(pending, timeout=timeout)
|
|
480
|
+
if still_running:
|
|
481
|
+
logger.warning("[WARN] %d task(s) ignored cancellation; abandoning them", len(still_running))
|
|
482
|
+
|
|
483
|
+
# 调用 ---------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
def _start_call(self, request: wire.Request, body: bytes) -> None:
|
|
486
|
+
"""为请求启动调用任务;调用 id 与在途调用重复时直接以 `invalid_args` 拒绝。"""
|
|
487
|
+
call_id = request.id
|
|
488
|
+
ctx = Command(id=call_id, cmd=request.cmd, window=request.window)
|
|
489
|
+
if call_id in self._calls:
|
|
490
|
+
self._send_error(ctx, "invalid_args", f"duplicate call id {call_id}")
|
|
491
|
+
return
|
|
492
|
+
task = self._spawn(self._handle_call(ctx, request, body))
|
|
493
|
+
self._calls[call_id] = (task, ctx)
|
|
494
|
+
|
|
495
|
+
def on_done(t: asyncio.Future[Any]) -> None:
|
|
496
|
+
# 同一 id 可能已被取消后复用,只移除属于本任务的条目。
|
|
497
|
+
entry = self._calls.get(call_id)
|
|
498
|
+
if entry is not None and entry[0] is t:
|
|
499
|
+
self._calls.pop(call_id, None)
|
|
500
|
+
|
|
501
|
+
task.add_done_callback(on_done)
|
|
502
|
+
|
|
503
|
+
def _cancel_call(self, call_id: int) -> None:
|
|
504
|
+
entry = self._calls.pop(call_id, None)
|
|
505
|
+
if entry is not None:
|
|
506
|
+
task, ctx = entry
|
|
507
|
+
ctx.cancelled = True
|
|
508
|
+
task.cancel()
|
|
509
|
+
|
|
510
|
+
async def _handle_call(self, ctx: Command, request: wire.Request, body: bytes) -> None:
|
|
511
|
+
"""执行调用并将结果或异常映射为 `Response` / `Error`;结束后关闭本次调用的通道。"""
|
|
512
|
+
channels: dict[str, Channel[Any]] = {}
|
|
513
|
+
try:
|
|
514
|
+
result = await self._execute(ctx, request, body, channels)
|
|
515
|
+
if not ctx.cancelled:
|
|
516
|
+
fields, result_body = split_payload(result)
|
|
517
|
+
self._send(wire.Response(id=ctx.id, **fields), result_body)
|
|
518
|
+
except asyncio.CancelledError:
|
|
519
|
+
if not ctx.cancelled:
|
|
520
|
+
self._send_error(ctx, "cancelled", "command was cancelled")
|
|
521
|
+
except TauriError as e:
|
|
522
|
+
self._send_error(ctx, e.code, e.message, e.data)
|
|
523
|
+
except FrameTooLarge as e:
|
|
524
|
+
self._send_error(ctx, "frame_too_large", str(e))
|
|
525
|
+
except Exception as e:
|
|
526
|
+
logger.exception("[ERROR] command %r failed", ctx.cmd)
|
|
527
|
+
data: dict[str, Any] = {"type": type(e).__name__}
|
|
528
|
+
if os.environ.get("TAURI_RUNTIME_DEBUG") == "1":
|
|
529
|
+
data["traceback"] = traceback.format_exc()
|
|
530
|
+
self._send_error(ctx, "internal", f"{type(e).__name__}: {e}", data)
|
|
531
|
+
finally:
|
|
532
|
+
for channel in channels.values():
|
|
533
|
+
channel.closed = True
|
|
534
|
+
|
|
535
|
+
async def _execute(
|
|
536
|
+
self, ctx: Command, request: wire.Request, body: bytes, channels: dict[str, Channel[Any]]
|
|
537
|
+
) -> Any:
|
|
538
|
+
handler = self._router.get(ctx.cmd)
|
|
539
|
+
if handler is None:
|
|
540
|
+
raise TauriError("command_not_found", f"command '{ctx.cmd}' not found")
|
|
541
|
+
try:
|
|
542
|
+
payload = join_payload(request, body)
|
|
543
|
+
except ProtocolError as e:
|
|
544
|
+
raise TauriError("invalid_args", f"invalid request body: {e}") from e
|
|
545
|
+
raw = payload if isinstance(payload, Raw) else None
|
|
546
|
+
args = None if raw is not None else payload
|
|
547
|
+
assert self._writer is not None
|
|
548
|
+
for name, channel_id in request.channels.items():
|
|
549
|
+
channels[to_snake(name)] = Channel(channel_id, self._writer)
|
|
550
|
+
kwargs = bind_arguments(handler, args, raw, channels, ctx, self)
|
|
551
|
+
return await self._invoke(handler.func, **kwargs)
|
|
552
|
+
|
|
553
|
+
def _send(self, message: wire.Message, body: bytes = b"") -> None:
|
|
554
|
+
if self._writer is not None:
|
|
555
|
+
self._writer.write(message, body)
|
|
556
|
+
|
|
557
|
+
def _send_error(self, ctx: Command, code: str, message: str, data: Any = None) -> None:
|
|
558
|
+
"""发送错误响应;`data` 无法编码或过大时去掉 `data` 重发,已取消的调用不发送。"""
|
|
559
|
+
if ctx.cancelled:
|
|
560
|
+
return
|
|
561
|
+
error = wire.Error(id=ctx.id, code=code, message=message, data=data)
|
|
562
|
+
try:
|
|
563
|
+
self._send(error)
|
|
564
|
+
except (ValueError, FrameTooLarge) as e:
|
|
565
|
+
if data is None:
|
|
566
|
+
raise
|
|
567
|
+
logger.warning("[WARN] dropped error data for call %d: %s", ctx.id, e)
|
|
568
|
+
self._send(error.model_copy(update={"data": None}))
|
|
569
|
+
|
|
570
|
+
# 事件 ---------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
def _deliver_event(self, message: wire.Event, body: bytes) -> None:
|
|
573
|
+
"""为该事件的每个监听器各启动一个后台任务。"""
|
|
574
|
+
try:
|
|
575
|
+
payload = join_payload(message, body)
|
|
576
|
+
except ProtocolError as e:
|
|
577
|
+
logger.warning("[WARN] dropped event frame: %s", e)
|
|
578
|
+
return
|
|
579
|
+
event = Event(message.event, payload, message.window)
|
|
580
|
+
for func in self._events.get(event.name):
|
|
581
|
+
self._spawn(self._run_listener(func, event))
|
|
582
|
+
|
|
583
|
+
async def _run_listener(self, func: Callable[..., Any], event: Event) -> None:
|
|
584
|
+
try:
|
|
585
|
+
await self._invoke(func, event)
|
|
586
|
+
except Exception:
|
|
587
|
+
logger.exception("[ERROR] listener for event %r failed", event.name)
|