wire

package
v0.23.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 7, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package wire 执行沙箱的**跨进程调用协议**。

它解决什么

沙箱档位(Level)答的是「能力面多强」,预算支持面(Capable)答的是「资源兜底 兑现哪几项」。但这两者都还缺一块:**程序一旦出宿主进程,工具回调就够不着了**。

进程内执行(goja VM 跑在宿主进程里)时,程序里的 `host.<tool>()` 是一次普通 Go 函数调用;程序出宿主进程后,它变成一次**跨进程 IPC**。这段 IPC 若由每个 后端各写一遍,就会有一堆互不兼容的私有协议——而协议恰恰是最不该私有的东西 (它决定了「子进程里的程序怎么跟父侧说话」,换个后端就得换一套)。

故本包把协议**收成一份**:帧格式、请求/响应语义、父侧服务端循环、子侧客户端。 后端只实现「怎么起这个进程」与「怎么执行这个程序」。

帧格式

长度前缀 + JSON:

┌───────────────┬──────────────────────────────┐
│ 4B 大端长度 N  │ N 字节 JSON 负载             │
└───────────────┴──────────────────────────────┘

**不用行分隔**——工具输出可能含换行,行分隔会被内容切断(一条多行的日志就能 把后面的帧全毁掉)。长度前缀对负载内容零假设。

六条不变量

  1. **版本响亮失败**:`v` 不匹配即拒,不静默错配。错配的症状是「字段读成了 另一个意思」——那是最难查的一类,故不静默。
  2. **失败分类跨进程同源**:`kind` 取 `guardx/toolfail` 的分类,**不是**自由 字符串。分类一旦退化成字符串,父侧就分不清「可重试」与「永久拒绝」, 重试策略随之瞎掉。`toolfail` 与 `sandbox` 同在 guardx——**同层引用, 无跨仓依赖**。
  3. **预算的权威在父侧**:调用数与输出字节数由**父侧**记账。子进程只是执行者 ——若子进程自己记账,它可以谎报自己没超预算。
  4. **在途调用有硬上限**:总量预算(MaxCalls)拦不住「瞬间发起十万次并发 调用」——那时它们都还没返回,父侧已被 goroutine 淹没。故父侧另设**在途 并发上限**,超出即回「超限」。这是**可用性**约束,与 MaxCalls 的**配额** 约束是两件事。
  5. **取消传播**:父侧 ctx 取消 → 取消在途调用 + 通知子进程终止(否则子进程 继续跑,父侧却已经返回了)。
  6. **响应按 `id` 关联,不按到达顺序**:程序里多个 await 是并发的,返回顺序与 请求顺序无关。

谁该用它

后端实现方(如 agent-harness 的 ptc 子进程后端):起进程、连管道、跑程序; **协议本身用本包的 Transport / Server / Client**。

宿主:只声明「这次执行要哪一档 + 哪些预算项」,不碰协议。

Index

Constants

View Source
const DefaultMaxInflight = 64

DefaultMaxInflight 在途调用数默认硬上限。

取 64:既能吃住「程序内并发几十次工具调用」的常见形态,又不至于让单个失控 程序把父侧的 goroutine 堆爆。宿主可调。

View Source
const MaxFrameBytes = 16 << 20

MaxFrameBytes 单帧负载上限(字节)。

长度前缀是 4 字节,理论上限 4GB——但那是**攻击面**:一个坏的长度前缀就能让 接收方预分配几 GB。故设上限,超限即拒。

默认 16MB 覆盖了「工具返回一个大结果」的常见情形(宿主侧另有 MaxBytes 预算 逐次把关,见不变量 3)。

View Source
const Version = 1

Version 协议版本。

**不匹配即响亮失败**(不变量 1):静默错配的症状是「字段读成了另一个意思」—— 那是最难查的一类错误,故版本对不上直接拒,不试图「兼容」。

Variables

View Source
var ErrInflightLimit = errors.New("wire: 在途调用数超过硬上限")

ErrInflightLimit 在途调用数超过硬上限(不变量 4)。

与「配额超限」(MaxCalls)是**两件事**:配额是「总共能调多少次」,在途上限是 「同时能挂多少个」。后者拦的是「瞬间发起十万次并发调用」——那时它们都还没 返回,父侧已被 goroutine 淹没。

Functions

func ReadFrame

func ReadFrame(r io.Reader, v any) error

ReadFrame 读一帧:读 4 字节大端长度,再读该长度的 JSON 负载。

长度超上限即拒(不预分配)——长度前缀是外部输入,不能信。 读到一半(EOF / 连接断)即报错,不返回「半个帧」。

func WriteFrame

func WriteFrame(w io.Writer, v any) error

WriteFrame 写一帧:4 字节大端长度 + JSON 负载。

一次 Write 发出整帧(长度前缀与负载合并进同一个缓冲)——分两次写会让 对端在两次之间读到半个帧,虽然不至于错乱,但会让并发场景下的帧交错变得可能。

Types

type Budget

type Budget struct {
	// MaxCalls 总调用数上限。0 = 不限。
	MaxCalls int
	// MaxBytes 累计输出字节上限。0 = 不限。
	MaxBytes int
	// contains filtered or unexported fields
}

Budget 父侧记账的预算(不变量 3)。

与 `sandbox.Budget` 的区别:那是「宿主对**后端**的要求」(哪些项要兜住), 这里是「父侧**实际记着**的账」(已用掉多少)。两者不是一回事——要求的是 上限,实际的是消耗。

func (*Budget) Used

func (b *Budget) Used() (calls, bytes int)

Used 返回已用的调用数与字节数。

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client 子侧客户端:程序在子进程里用它向父侧请求工具调用。

与 Transport 的方向相反

Transport 是**父侧**用的(父侧发起调用、收响应);Client 是**子侧**用的 (子侧发起调用、收响应)。两者共用同一套帧格式与「按 id 关联」语义—— 协议只有一份,两个方向只是角色不同。

取消传播(不变量 5)

父侧 ctx 取消 → 父侧关闭传输端 → 子侧读到 EOF → 本客户端的在途调用全部失败。 子侧不需要额外的取消通道:连接断本身就是最强的取消信号。

func NewClient

func NewClient(rw io.ReadWriter) *Client

NewClient 构造子侧客户端。启动即有读循环 goroutine。

func (*Client) Call

func (c *Client) Call(ctx context.Context, tool, args string) (Response, error)

Call 向父侧发起一次工具调用并等待响应。

id 省略时自动生成(保证一次执行内唯一)。

func (*Client) CallWithID

func (c *Client) CallWithID(ctx context.Context, id, tool, args string) (Response, error)

CallWithID 用指定 id 发起调用。

func (*Client) Close

func (c *Client) Close() error

Close 关闭客户端(幂等)。

type Handler

type Handler func(ctx context.Context, req Request) (Response, error)

Handler 父侧处理一次工具调用。

返回的 Response 若 OK=false,**Kind 必须是 toolfail 的分类**(不变量 2)—— 分类一旦退化成自由字符串,子进程侧就分不清「可重试」与「永久拒绝」。

Handler 返回的 error 会被转成「分类 = unknown」的失败帧;能归类的失败请自己填 Response.Kind(宿主最清楚那次失败属于哪一类)。

type Request

type Request struct {
	// V 协议版本。
	V int `json:"v"`
	// ID 调用标识。响应按它关联(不变量 6),故**必须非空且在一次执行内唯一**。
	ID string `json:"id"`
	// Tool 工具名。
	Tool string `json:"tool"`
	// Args 工具参数原文(宿主侧按自己的工具面解析)。
	Args string `json:"args"`
	// DeadlineMS 本次调用的期限(毫秒)。0 = 不限期。
	//
	// 由**子进程**声明、**父侧**执行:父侧是记账与取消的权威(不变量 3/5),
	// 子进程说了不算,父侧必须自己兜住。
	DeadlineMS int64 `json:"deadline_ms,omitempty"`
}

Request 一次工具调用请求帧(子进程 → 父侧)。

Args 是**原始参数串**(通常是 JSON 文本),不解析成结构体——协议不该替宿主 决定工具参数长什么样(那是宿主的工具面知识)。

type Response

type Response struct {
	// V 协议版本。
	V int `json:"v"`
	// ID 对应的请求标识(不变量 6)。
	ID string `json:"id"`
	// OK 本次调用是否成功。
	OK bool `json:"ok"`
	// Value 成功时的结果文本(编码由宿主工具面定)。
	Value string `json:"value,omitempty"`
	// Kind 失败时的分类(**toolfail.Class**,不变量 2)。
	//
	// 空串只允许在「不是工具失败」的场合(如协议层错误、父侧拒绝)——那类错误由
	// 父侧直接终止执行域,不走正常响应路径。
	Kind string `json:"kind,omitempty"`
	// Error 失败时的可读原因(给人看,不参与判定)。
	Error string `json:"error,omitempty"`
}

Response 一次调用响应帧(父侧 → 子进程)。

type Result

type Result struct {
	// V 协议版本。
	V int `json:"v"`
	// ResultID 对应 Start.ResultID。
	ResultID string `json:"result_id"`
	// OK 执行是否成功。
	OK bool `json:"ok"`
	// Value 成功时的结果文本(编码由后端定)。
	Value string `json:"value,omitempty"`
	// Kind 失败时的分类(**toolfail.Class**,不变量 2)。
	Kind string `json:"kind,omitempty"`
	// Error 失败时的可读原因(给人看,不参与判定)。
	Error string `json:"error,omitempty"`
	// Calls 本次执行实际发起的工具调用次数(父侧用于对账)。
	Calls int `json:"calls,omitempty"`
	// Bytes 本次执行实际产生的输出字节数(父侧用于对账)。
	Bytes int `json:"bytes,omitempty"`
}

Result 子侧 → 父侧:一次执行的结果。

与 Response 的区别:Response 是一次**工具调用**的结果,Result 是**整次执行** 的结果(程序跑完了没有、跑了什么、错在哪)。

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner 子侧启动通道的对端:收父侧下发的 Start、回报执行结果。

与 Client 的分工(两条通道,两个方向)

启动通道:父侧 Starter  ←→  子侧 Runner   (一次执行:1 个 Start + 1 个 Result)
调用通道:父侧 Server  ←→  子侧 Client   (N 次工具调用)

**两条通道必须是两条连接**。若共用一条,「读到的这帧是 Start 还是 Response」 就得靠猜——而两者的 JSON 形状不同(Request/Response 有 tool/id,Start 有 program/bindings),猜错的后果是「字段读成了另一个意思」,那是最难查的一类 协议 bug。故本包把两个方向的四种角色各给一个类型。

func NewRunner

func NewRunner(rw io.ReadWriter) *Runner

NewRunner 构造子侧启动通道对端。

func (*Runner) AwaitStart

func (rn *Runner) AwaitStart(ctx context.Context) (*Start, error)

AwaitStart 等父侧下发一次执行的启动帧。

子侧的典型流程:连上启动通道 → AwaitStart(父侧告诉它跑什么)→ 跑程序 (期间用**另一条**调用通道的 Client 请求工具)→ SendResult 回报结果。

**只等一帧**——一次执行一个 Start。

ctx 到期或取消时**必然返回**(#79):主动关底层连接(可关时)解除阻塞读, 返回包装了 ctx.Err() 的错误,不再无期等父侧。底层不实现 io.Closer 时 ctx 路径仍会返回,但阻塞中的读 goroutine 要等连接自然断开才退出。

func (*Runner) SendResult

func (rn *Runner) SendResult(res Result) error

SendResult 回报一次执行的结果。

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server 父侧服务端:读子进程发来的调用请求,执行后回帧。

父侧是**应答方**(谁发起、谁应答)

程序在子进程里(`host.<tool>()` 由程序发起),故**子侧是发起方**(Client), 父侧是应答方(Server)。方向搞反会导致记账记在错误的一侧—— 配额该在**接受调用**时扣,不是谁发起谁扣。

它同时是**预算的权威**(不变量 3)

调用数与输出字节数由**父侧**记账。子进程只是执行者——若子进程自己记账, 它可以谎报自己没超预算。故 Budget 挂在 Server 上,由父侧逐次扣减。

并发处理 + 在途硬上限(不变量 4)

每个请求在独立 goroutine 里处理,故一次慢调用不阻塞其他调用。总量预算 (MaxCalls)拦不住「瞬间发起十万次并发调用」——那时它们都还没返回,父侧已被 goroutine 淹没。故另设**在途并发上限**,超出即回「超限」帧,**不排队等**。

这是**可用性**约束,与 MaxCalls 的**配额**约束是两件事。

单次调用期限由父侧执行(#78)

Request.DeadlineMS 是子侧的声明,**父侧执行**:handler 收到的 ctx 带期限, 期限到父侧直接回类型化失败帧(kind=transient),不等 handler。子侧声明只能 把期限**调短**:超过宿主上限(WithMaxCallDeadline)按上限执行。

取消传播(不变量 5)

父侧 ctx 取消 → 关闭连接 → 子侧读到 EOF → 子侧全部在途调用失败。 不需要单独的取消帧:**管道关闭本身就是最强的取消信号**。

func NewServer

func NewServer(rw io.ReadWriter, h Handler, opts ...ServerOption) *Server

NewServer 构造父侧服务端。

func (*Server) Close

func (s *Server) Close() error

Close 关闭服务端(幂等,#80 接线)。

「关闭只停接受新调用」由此真正落地:

  • 置停收标志:Serve 循环逐帧检查,不再受理新请求;
  • 关底层连接(若实现 io.Closer):解除阻塞中的读,Serve 干净返回 nil; 子侧随即读到 EOF,其在途调用全部失败(不变量 5——管道关闭就是取消信号)。

已在途的调用仍由各自 goroutine 收尾(向已关连接回帧会失败、被忽略)。 底层不实现 io.Closer 时标志立即生效,但阻塞中的那次读要等连接自然断开。

func (*Server) Serve

func (s *Server) Serve(ctx context.Context) error

Serve 读-执行-回帧,直到 EOF、Close 或出错。

**协议层错误即终止**:版本不匹配、帧损坏——这些不是「某次调用失败」, 是「两侧说的不是同一种话」,继续回帧只会产出更难查的错。

Close() 停收(#80):停收标志置位后不再受理新请求;若底层连接可关, Close 已顺带关了它,阻塞中的读被解除,本循环干净返回 nil。

type ServerOption

type ServerOption func(*Server)

ServerOption Server 的构造选项。

func WithBudget

func WithBudget(b Budget) ServerOption

WithBudget 设父侧记账的预算(不变量 3)。零值 = 不限。

func WithMaxCallDeadline added in v0.20.0

func WithMaxCallDeadline(d time.Duration) ServerOption

WithMaxCallDeadline 设宿主侧的单次调用期限上限(#78)。

这是父侧对子侧声明(Request.DeadlineMS)的**钳制**:声明比它长按它执行, 不声明也按它执行(父侧必须自己兜住,子进程说了不算);声明比它短则按声明 (子侧只能把期限调短,不能调长)。<=0 = 不设上限(只执行子侧声明)。

func WithMaxInflight

func WithMaxInflight(n int) ServerOption

WithMaxInflight 设在途调用硬上限(不变量 4)。<=0 用默认值。

type Start

type Start struct {
	// V 协议版本。
	V int `json:"v"`
	// Program 被执行的程序体。
	//
	// **由后端自己解释**(不变量:内核不规定程序语言)——子侧知道这段程序
	// 对自己意味着什么(脚本?DSL?),父侧只负责把它送到。
	Program string `json:"program"`
	// Bindings 程序可用的绑定清单。
	//
	// 父侧把绑定**清单**(名字与调用形态)交给子侧,而不是把绑定**实现**交出去
	// ——实现留在父侧,子侧通过工具调用帧请求执行。这正是跨进程的意义:
	// 宿主工具只在宿主进程里存在。
	Bindings []StartBinding `json:"bindings"`
	// Budget 这次执行的要求(父侧对后端的要求)。
	//
	// 与父侧记账用的 Budget 不同:那是「实际记着多少」,这是「要求多少」。
	Budget StartBudget `json:"budget"`
	// ResultID 本次执行的结果标识(子侧回报结果时带回)。
	ResultID string `json:"result_id"`
}

Start 父侧 → 子侧:启动一次执行。

为什么它与 Request 是两类帧

Request 是「子侧向父侧要一次工具调用」,Start 是「父侧告诉子侧跑什么」。 两者层次不同:一次执行里可能有**零到很多**次工具调用。

混在一类帧里的代价是「帧的含义要靠上下文猜」——那是最难查的一类协议 bug。 故分成两个类型,各自有明确的发起方向。

type StartBinding

type StartBinding struct {
	// Name 工具名(父侧的原始名)。
	Name string `json:"name"`
	// Signature JS 调用形态,如 "get_logs(host, count)"。
	//
	// 空 = 退化为 `host.<name>(argsJSON)`。
	Signature string `json:"signature,omitempty"`
}

StartBinding 绑定清单项:名字与调用形态。

**不含实现**——实现留在父侧(子侧通过工具调用帧请求执行)。

type StartBudget

type StartBudget struct {
	// WallClock 墙钟期限。
	WallClock time.Duration `json:"wall_clock,omitempty"`
	// MaxCalls 调用数上限。
	MaxCalls int `json:"max_calls,omitempty"`
	// MaxBytes 输出字节上限。
	MaxBytes int `json:"max_bytes,omitempty"`
	// MaxMemory 内存上限(字节)。0 = 不要求。
	//
	// 子侧**兜不住**这一项时应当拒绝(而不是假装兜住了)——这与
	// `sandbox.Capable` 的缺省语义同源:漏声明得到的是「兜不住」。
	MaxMemory int64 `json:"max_memory,omitempty"`
}

StartBudget 父侧对这次执行的要求。

type Starter

type Starter struct {
	// contains filtered or unexported fields
}

Starter 父侧启动器:把一次执行的启动信息发给子侧,并收回结果。

为什么单独一个类型(不塞进 Server)

Server 是**应答方**(子侧发起工具调用,父侧应答);Starter 是**发起方** (父侧告诉子侧跑什么,子在回报结果)。两者方向相反、职责不同, 塞进一个类型会让「谁在等谁」变得要看实现才知道。

启动通道与调用通道是**两条**

启动是一次性的(发一帧 Start、收一帧 Result),工具调用是多次的 (N 次 Request/Response)。混在一条连接上则「读到的那帧是哪种」要靠猜。 故 Starter 用自己的连接;后端起子进程时开两条管道(启动 + 调用)。

ctx 兜底(#79)

子程序**活着但不回帧**(死循环、被注入的劣化代码——正是 wire 隔离的威胁面) 时管道不会自己断,阻塞读永远不会醒:ctx 到期/取消时本类型会**主动关掉底层 连接**(实现 io.Closer 时)——一来解除阻塞中的读写,二来让子侧读到 EOF 自行 终止(不变量 5:管道关闭本身就是最强的取消信号)。故 Start 在「ctx 超时」 与「连接断开」两种情形下都**必然返回错误**,不会挂死。

底层连接不实现 io.Closer 时 ctx 路径仍会返回,但阻塞中的读 goroutine 要等 连接自然断开才退出;后端装配时请给启动通道一条可关的连接。

func NewStarter

func NewStarter(rw io.ReadWriter) *Starter

NewStarter 构造父侧启动器。

func (*Starter) Start

func (s *Starter) Start(ctx context.Context, start Start) (Result, error)

Start 发送启动帧并等待子侧回报结果。

一次性语义:一次 Start 对应一次执行。子侧跑完(成功、失败、或被内核杀掉 都没回帧)时,父侧会在 ctx 超时或连接断开时拿到错误——**「子进程被 OOM 杀掉」正是这条路径**:管道 EOF,不是「程序返回空」。

ctx 到期或取消时**必然返回**(#79):先关底层连接(可关时)解除阻塞、 通知子侧终止,再返回包装了 ctx.Err() 的错误。写帧阶段(子侧连 Start 都不读)同样被 ctx 兜住。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL