Files
EasyTier/easytier-go/internal/coreabi/dataplane.go
T
KKRainbow e313ba8efb fix(wasi): reduce startup RSS, align ABI v4 and wire v3 (#2588)
* feat(easytier-go): accept data plane ABI v4

The core raised DATA_PLANE_ABI_VERSION to 4 in f26c2aa1 ("feat(wasi):
run EasyTier core on Cloudflare Workers and browsers") to advertise the
new guest exports easytier_data_plane_tcp_shutdown_write_submit and
easytier_data_plane_tcp_shutdown_write_result_take, which let a host
half-close guest TCP streams.

The Go host has no caller for half-close: net.Conn exposes only Close,
which already tears down both directions, so the v3 behavior is
preserved and no new plumbing is added. Without this bump, any artifact
rebuilt from current core source is rejected at instance creation with
"unsupported EasyTier data plane ABI version 4, want 3".

* fix(easytier-go): decode socket options wire v3, rebuild core artifact

38e2a621 ("refactor(ohos): 拆分 OHRS 包并按 socket 精细保护 VPN 流量")
raised the host socket options wire format from version 2 to 3: a
need_protect byte is appended after the purpose byte in TCP connect,
UDP bind, and TCP listen options, shifting bind_device one byte later.
The Go hostABI decoders were never updated, so every socket operation
from a HEAD-built core was rejected as "invalid options" and required
listeners failed to start.

Bump the accepted wire version to 3 and skip the need_protect byte in
all three decoders. The byte requests VPN socket protection, which only
Android-style VPN hosts can honor; on every other platform sockets are
already protected, so reading and discarding it is correct.

Regenerate the embedded core artifact and protobuf bindings from HEAD
(599e4eac) so the shipped artifact matches the Go host again. The proto
regeneration also picks up schema fields added since the last embed
(e.g. prefer_peer_relay).

* perf(easytier-go): release compiler garbage after host init

wazero's optimizing compiler allocates ~100MB of throwaway state on
the Go heap while compiling the embedded core. Go's runtime does not
return that memory to the OS after the initiating GC, so the process
retained the compilation peak for its entire lifetime: RSS sat at
~164MB before any instance or network activity.

Call debug.FreeOSMemory() once after the module is instantiated.
NewHost is a one-time initialization path outside the dataplane, so
the stop-the-world pass is safe here. Measured RSS after host.New
drops from ~164MB to ~64MB; no behavioral change.

* chore(easytier-js): bump toolchain dependencies

- vitest 2.1.9 -> 3.2.7 (all three packages)
- esbuild 0.25.9 -> 0.28.2 (browser bundler)
- wrangler 4.114.0 -> 4.134.0 (cloudflare + web example)
- @cloudflare/workers-types 5.20260724.1 -> 5.20260917.1
- binaryen 131.0.0 -> 132.0.0 (JS bindings only; the wasm build
  uses the standalone wasm-opt binary fetched by the build script)

Not bumped: typescript stays at 5.9.3 (latest 5.x; 7.0 is a major
jump not worth taking for this workspace), vite stays at 5.4.21
(web-example only; 5->8 spans three majors).

Verified with pnpm test (28 + 2 + 2 tests across runtime, browser,
cloudflare) and pnpm check (tsc + wrangler deploy --dry-run).
pnpm-workspace.yaml gained three minimumReleaseAgeExclude entries
recorded automatically by pnpm for freshly published versions.

* fix(easytier-js): align host wire format with core wire v3

The core socket options wire format moved to version 3 in 38e2a621
(need_protect byte after purpose). The JS websocket-host still
required version 2 and rejected every TCP bind from a HEAD-built core,
breaking browser port leases.

- websocket-host.ts: accept version 3, minimum length 49. The
  need_protect byte sits after purpose (offset 43) and is a no-op
  outside Android VPN hosts, so the decoder just skips it.
- websocket-host.test.ts: update the test encoder to emit v3.
- binaryen stays at 131.0.0 to match script/build-wasi-core.sh, which
  intentionally pins binaryen 131 for the Go-side embedded core.
  The npm binaryen package provides the wasm-opt binary used by
  build-wasm.mjs, so keeping both sides on the same version avoids
  divergent optimization output.
- pnpm-workspace.yaml: drop two stale minimumReleaseAgeExclude entries
  for @cloudflare/workers-types versions no longer in the lockfile.

* test(easytier-go): cover socket options wire v3 decoders

Direct unit tests for decodeTCPConnectOptions, decodeUDPBindOptions, and
decodeTCPListenOptions with wire v3 documents. Covers combinations of
socket mark, netns, bind device, and local address presence, plus
rejection of wire v2.

Resolves review feedback on PR #2588.
2026-09-19 00:13:47 +08:00

716 lines
16 KiB
Go

package coreabi
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"net/netip"
"github.com/easytier/easytier/easytier-go/internal/contextutil"
)
const (
DataPlaneABIVersion = 4
DataPlaneCapability uint64 = 1 << 0
DataPlaneTCPCapability uint64 = 1 << 1
DataPlaneUDPCapability uint64 = 1 << 2
DeadlineRead DeadlineDirection = 1 << 0
DeadlineWrite DeadlineDirection = 1 << 1
requiredDataPlaneCapabilities = DataPlaneCapability |
DataPlaneTCPCapability |
DataPlaneUDPCapability
)
type OperationID uint64
type ResourceID uint64
type DeadlineDirection uint32
type OperationKind uint16
const (
OperationTCPConnect OperationKind = iota + 1
OperationTCPBind
OperationTCPAccept
OperationTCPRead
OperationTCPWrite
OperationUDPBind
OperationUDPReceive
OperationUDPSend
)
func (kind OperationKind) valid() bool {
return kind >= OperationTCPConnect && kind <= OperationUDPSend
}
type ErrorKind uint16
const (
ErrorNone ErrorKind = iota
ErrorCancelled
ErrorDeadlineExceeded
ErrorInstanceStopped
ErrorHandleClosed
ErrorNoOverlayRoute
ErrorPathNotReady
ErrorAddressFamilyUnsupported
ErrorAddressInUse
ErrorConnectionRefused
ErrorNetworkChanged
ErrorResourceLimit
ErrorIO
ErrorBufferTooSmall
)
func (kind ErrorKind) validCompletionStatus() bool {
return kind <= ErrorBufferTooSmall
}
type DataPlaneError struct {
Kind ErrorKind
Message string
}
func (err *DataPlaneError) Error() string {
if err.Message == "" {
return fmt.Sprintf("data plane error %d", err.Kind)
}
return err.Message
}
type Completion struct {
Operation OperationID
Kind OperationKind
Status ErrorKind
}
type OperationResult struct {
Kind OperationKind
Resource ResourceID
Local netip.AddrPort
Peer netip.AddrPort
Data []byte
Length int
EOF bool
Truncated bool
}
func (core *Core) validateDataPlaneABI(ctx context.Context) error {
version, err := core.callOne(ctx, "easytier_data_plane_abi_version")
if err != nil {
return err
}
if version != DataPlaneABIVersion {
return fmt.Errorf(
"unsupported EasyTier data plane ABI version %d, want %d",
version,
DataPlaneABIVersion,
)
}
capabilities, err := core.callOne(ctx, "easytier_data_plane_capabilities")
if err != nil {
return err
}
if missing := requiredDataPlaneCapabilities &^ capabilities; missing != 0 {
return fmt.Errorf(
"EasyTier data plane capabilities %#x are missing %#x",
capabilities,
missing,
)
}
return nil
}
func (core *Core) SubmitTCPConnect(
ctx context.Context,
peer netip.AddrPort,
timeoutMillis uint64,
) (OperationID, error) {
wire, err := encodeSocketAddress(peer)
if err != nil {
return 0, err
}
return core.submitWithInput(
ctx,
"easytier_data_plane_tcp_connect_submit",
wire[:],
timeoutMillis,
)
}
func (core *Core) SubmitTCPBind(
ctx context.Context,
port uint16,
timeoutMillis uint64,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_tcp_bind_submit",
uint64(port),
timeoutMillis,
)
}
func (core *Core) SubmitTCPAccept(
ctx context.Context,
listener ResourceID,
timeoutMillis uint64,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_tcp_accept_submit",
uint64(listener),
timeoutMillis,
)
}
func (core *Core) SubmitTCPRead(
ctx context.Context,
stream ResourceID,
maximum uint32,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_tcp_read_submit",
uint64(stream),
uint64(maximum),
)
}
func (core *Core) SubmitTCPWrite(
ctx context.Context,
stream ResourceID,
data []byte,
) (operation OperationID, err error) {
if len(data) > maxDataPlaneTransferBytes {
return 0, fmt.Errorf(
"TCP write length %d exceeds data plane limit %d",
len(data),
maxDataPlaneTransferBytes,
)
}
pointer, err := core.writeInput(ctx, data)
if err != nil {
return 0, err
}
if pointer != 0 {
defer core.cleanupBuffer(ctx, pointer, &err)
}
return core.submit(
ctx,
"easytier_data_plane_tcp_write_submit",
uint64(stream),
uint64(pointer),
uint64(len(data)),
)
}
func (core *Core) SubmitUDPBind(
ctx context.Context,
port uint16,
timeoutMillis uint64,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_udp_bind_submit",
uint64(port),
timeoutMillis,
)
}
func (core *Core) SubmitUDPReceive(
ctx context.Context,
socket ResourceID,
maximum uint32,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_udp_receive_submit",
uint64(socket),
uint64(maximum),
)
}
func (core *Core) SubmitUDPSend(
ctx context.Context,
socket ResourceID,
peer netip.AddrPort,
data []byte,
) (operation OperationID, err error) {
if len(data) > maxDataPlaneTransferBytes {
return 0, fmt.Errorf(
"UDP send length %d exceeds data plane limit %d",
len(data),
maxDataPlaneTransferBytes,
)
}
address, err := encodeSocketAddress(peer)
if err != nil {
return 0, err
}
addressPointer, err := core.ensureDataPlaneAddress(ctx)
if err != nil {
return 0, err
}
if !core.module.Memory().Write(addressPointer, address[:]) {
return 0, fmt.Errorf("write UDP peer address to guest memory")
}
var dataPointer uint32
if len(data) != 0 {
dataPointer, err = core.ensureDataPlaneInput(ctx, uint32(len(data)))
if err != nil {
return 0, err
}
if !core.module.Memory().Write(dataPointer, data) {
return 0, fmt.Errorf("write UDP payload to guest memory")
}
}
return core.submit(
ctx,
"easytier_data_plane_udp_send_submit",
uint64(socket),
uint64(addressPointer),
uint64(dataPointer),
uint64(len(data)),
)
}
func (core *Core) SetResourceDeadline(
ctx context.Context,
resource ResourceID,
direction DeadlineDirection,
timeoutMillis uint64,
) error {
result, err := core.callOne(
ctx,
"easytier_data_plane_resource_deadline_set",
core.handle,
uint64(resource),
uint64(direction),
timeoutMillis,
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.dataPlaneStatusError(
ctx,
"set data plane resource deadline",
status,
)
}
return nil
}
func (core *Core) submitWithInput(
ctx context.Context,
name string,
input []byte,
params ...uint64,
) (operation OperationID, err error) {
pointer, err := core.writeInput(ctx, input)
if err != nil {
return 0, err
}
defer core.cleanupBuffer(ctx, pointer, &err)
return core.submit(ctx, name, append([]uint64{uint64(pointer)}, params...)...)
}
func (core *Core) submit(
ctx context.Context,
name string,
params ...uint64,
) (operation OperationID, err error) {
pointer, err := core.ensureDataPlaneOutput(ctx, operationIDLength)
if err != nil {
return 0, err
}
callParams := make([]uint64, 0, len(params)+2)
callParams = append(callParams, core.handle)
callParams = append(callParams, params...)
callParams = append(callParams, uint64(pointer))
result, err := core.callOne(ctx, name, callParams...)
if err != nil {
return 0, err
}
if status := int32(result); status != 0 {
return 0, core.dataPlaneStatusError(ctx, name, status)
}
wire, ok := core.module.Memory().Read(pointer, operationIDLength)
if !ok {
return 0, fmt.Errorf("read operation ID from guest memory")
}
operation = OperationID(binary.BigEndian.Uint64(wire))
if operation == 0 {
return 0, fmt.Errorf("%s returned zero operation ID", name)
}
return operation, nil
}
func (core *Core) DrainCompletions(
ctx context.Context,
maximum uint32,
) (completions []Completion, err error) {
if maximum == 0 {
return nil, nil
}
if uint64(maximum)*completionLength > math.MaxUint32 {
return nil, fmt.Errorf("completion batch %d exceeds guest address space", maximum)
}
length := maximum * completionLength
pointer, err := core.ensureDataPlaneOutput(ctx, length)
if err != nil {
return nil, err
}
result, err := core.callCached(
ctx,
"easytier_data_plane_completion_drain",
&core.completionFunction,
core.handle,
uint64(pointer),
uint64(maximum),
)
if err != nil {
return nil, err
}
count := int32(result)
if count < 0 {
return nil, core.dataPlaneStatusError(
ctx,
"drain data plane completions",
count,
)
}
if uint32(count) > maximum {
return nil, fmt.Errorf(
"data plane drained %d completions into capacity %d",
count,
maximum,
)
}
wire, ok := core.module.Memory().Read(
pointer,
uint32(count)*completionLength,
)
if !ok {
return nil, fmt.Errorf("read data plane completions from guest memory")
}
return decodeCompletions(wire)
}
func (core *Core) TakeResult(
ctx context.Context,
completion Completion,
) (OperationResult, error) {
result := OperationResult{Kind: completion.Kind}
var err error
switch completion.Kind {
case OperationTCPConnect, OperationTCPAccept:
result.Resource, result.Local, result.Peer, err =
core.takeStreamResult(ctx, completion)
case OperationTCPBind, OperationUDPBind:
result.Resource, result.Local, err =
core.takeBindResult(ctx, completion)
case OperationTCPRead:
result.Data, result.EOF, err = core.takeTCPReadResult(ctx, completion)
case OperationTCPWrite:
result.Length, err = core.takeLengthResult(
ctx,
completion,
"easytier_data_plane_tcp_write_result_take",
)
case OperationUDPReceive:
result.Data, result.Peer, result.Truncated, err =
core.takeUDPReceiveResult(ctx, completion)
case OperationUDPSend:
result.Length, err = core.takeLengthResult(
ctx,
completion,
"easytier_data_plane_udp_send_result_take",
)
default:
return OperationResult{}, fmt.Errorf(
"take unknown data plane operation kind %d",
completion.Kind,
)
}
return result, err
}
func (core *Core) takeStreamResult(
ctx context.Context,
completion Completion,
) (resource ResourceID, local, peer netip.AddrPort, err error) {
name := "easytier_data_plane_tcp_connect_result_take"
if completion.Kind == OperationTCPAccept {
name = "easytier_data_plane_tcp_accept_result_take"
}
wire, err := core.takeFixedResult(ctx, completion, name, streamResultLength)
if err != nil {
return 0, netip.AddrPort{}, netip.AddrPort{}, err
}
return decodeStreamResult(wire)
}
func (core *Core) takeBindResult(
ctx context.Context,
completion Completion,
) (resource ResourceID, local netip.AddrPort, err error) {
name := "easytier_data_plane_tcp_bind_result_take"
if completion.Kind == OperationUDPBind {
name = "easytier_data_plane_udp_bind_result_take"
}
wire, err := core.takeFixedResult(ctx, completion, name, bindResultLength)
if err != nil {
return 0, netip.AddrPort{}, err
}
return decodeBindResult(wire)
}
func (core *Core) takeFixedResult(
ctx context.Context,
completion Completion,
name string,
length uint32,
) (wire []byte, err error) {
pointer, err := core.ensureDataPlaneOutput(ctx, length)
if err != nil {
return nil, err
}
result, err := core.callOne(
ctx,
name,
core.handle,
uint64(completion.Operation),
uint64(pointer),
)
if err != nil {
return nil, err
}
if status := int32(result); status != 0 {
return nil, core.dataPlaneStatusError(ctx, name, status)
}
bytes, ok := core.module.Memory().Read(pointer, length)
if !ok {
return nil, fmt.Errorf("read %s from guest memory", name)
}
return append([]byte(nil), bytes...), nil
}
func (core *Core) takeTCPReadResult(
ctx context.Context,
completion Completion,
) (data []byte, eof bool, err error) {
data, metadata, err := core.takeVariableResult(
ctx,
completion,
"easytier_data_plane_tcp_read_result_take",
tcpReadMetadataLength,
)
if err != nil {
return nil, false, err
}
if metadata[0] > 1 {
return nil, false, fmt.Errorf(
"data plane returned invalid TCP EOF flag %d",
metadata[0],
)
}
return data, metadata[0] == 1, nil
}
func (core *Core) takeUDPReceiveResult(
ctx context.Context,
completion Completion,
) (data []byte, peer netip.AddrPort, truncated bool, err error) {
data, metadata, err := core.takeVariableResult(
ctx,
completion,
"easytier_data_plane_udp_receive_result_take",
udpReceiveMetadataLength,
)
if err != nil {
return nil, netip.AddrPort{}, false, err
}
peer, err = decodeSocketAddress(metadata[:socketAddressLength])
if err != nil {
return nil, netip.AddrPort{}, false, err
}
if metadata[socketAddressLength] > 1 {
return nil, netip.AddrPort{}, false, fmt.Errorf(
"data plane returned invalid UDP truncation flag %d",
metadata[socketAddressLength],
)
}
return data, peer, metadata[socketAddressLength] == 1, nil
}
func (core *Core) takeVariableResult(
ctx context.Context,
completion Completion,
name string,
metadataLength uint32,
) (data, metadata []byte, err error) {
capacity := uint32(maxDataPlaneTransferBytes)
if completion.Kind == OperationUDPReceive {
capacity = math.MaxUint16
}
dataPointer, err := core.ensureDataPlaneInput(ctx, capacity)
if err != nil {
return nil, nil, err
}
metadataPointer, err := core.ensureDataPlaneOutput(ctx, metadataLength)
if err != nil {
return nil, nil, err
}
result, err := core.callOne(
ctx,
name,
core.handle,
uint64(completion.Operation),
uint64(dataPointer),
uint64(capacity),
uint64(metadataPointer),
)
if err != nil {
return nil, nil, err
}
length := int32(result)
if length < 0 {
return nil, nil, core.dataPlaneStatusError(ctx, name, length)
}
if uint32(length) > capacity {
return nil, nil, fmt.Errorf(
"%s returned length %d into capacity %d",
name,
length,
capacity,
)
}
if length != 0 {
bytes, ok := core.module.Memory().Read(dataPointer, uint32(length))
if !ok {
return nil, nil, fmt.Errorf("read %s payload from guest memory", name)
}
data = append([]byte(nil), bytes...)
}
bytes, ok := core.module.Memory().Read(metadataPointer, metadataLength)
if !ok {
return nil, nil, fmt.Errorf("read %s metadata from guest memory", name)
}
return data, append([]byte(nil), bytes...), nil
}
func (core *Core) takeLengthResult(
ctx context.Context,
completion Completion,
name string,
) (int, error) {
result, err := core.callOne(
ctx,
name,
core.handle,
uint64(completion.Operation),
)
if err != nil {
return 0, err
}
length := int32(result)
if length < 0 {
return 0, core.dataPlaneStatusError(ctx, name, length)
}
return int(length), nil
}
func (core *Core) CancelOperation(
ctx context.Context,
operation OperationID,
) error {
return core.dataPlaneCallStatus(
ctx,
"easytier_data_plane_operation_cancel",
uint64(operation),
)
}
func (core *Core) FreeOperation(
ctx context.Context,
operation OperationID,
) error {
return core.dataPlaneCallStatus(
ctx,
"easytier_data_plane_operation_free",
uint64(operation),
)
}
func (core *Core) CloseResource(ctx context.Context, resource ResourceID) error {
return core.dataPlaneCallStatus(
ctx,
"easytier_data_plane_resource_close",
uint64(resource),
)
}
func (core *Core) dataPlaneCallStatus(
ctx context.Context,
name string,
params ...uint64,
) error {
callParams := append([]uint64{core.handle}, params...)
result, err := core.callOne(ctx, name, callParams...)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.dataPlaneStatusError(ctx, name, status)
}
return nil
}
func (core *Core) dataPlaneStatusError(
ctx context.Context,
operation string,
status int32,
) error {
if status >= 0 || status < -int32(ErrorBufferTooSmall) {
return core.statusError(ctx, operation, status)
}
message, err := core.errorMessage(ctx, core.handle)
if err != nil {
return errors.Join(
&DataPlaneError{Kind: ErrorKind(-status)},
fmt.Errorf("%s: %w", operation, err),
)
}
return &DataPlaneError{
Kind: ErrorKind(-status),
Message: message,
}
}
func (core *Core) writeInput(
ctx context.Context,
data []byte,
) (uint32, error) {
if len(data) == 0 {
return 0, nil
}
if uint64(len(data)) > math.MaxUint32 {
return 0, fmt.Errorf("guest input length %d exceeds wasm32", len(data))
}
pointer, err := core.allocate(ctx, uint32(len(data)))
if err != nil {
return 0, err
}
if !core.module.Memory().Write(pointer, data) {
_ = core.free(contextutil.WithoutCancel(ctx), pointer)
return 0, fmt.Errorf("write data plane input to guest memory")
}
return pointer, nil
}