From 3fb74ec6a8bbb78bf251244280ceca7dcb57d3ef Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Sun, 30 Aug 2026 19:10:41 +0400 Subject: [PATCH 1/2] wasm sockets: return the stream handles as one record so the host binds The canonical ABI folds a host method into one result, and the binder accepts either an exact arity match or a (payload, error) pair. finish-connect returned three values and accept four, so neither could ever bind: a guest importing them failed with 'result count mismatch: expected 1, got 3'. Those two methods are the only source of the socket's input and output streams, so wasi:sockets TCP was unusable end to end for every guest. Both now return a record plus the error, the shape the address getters already use. --- runtime/wasm/host/wippy/hosts/sockets/tcp.go | 47 +++++++++++++------ .../wasm/host/wippy/hosts/sockets/tcp_test.go | 24 ++++++---- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/runtime/wasm/host/wippy/hosts/sockets/tcp.go b/runtime/wasm/host/wippy/hosts/sockets/tcp.go index fb5e44dc9..f80418383 100644 --- a/runtime/wasm/host/wippy/hosts/sockets/tcp.go +++ b/runtime/wasm/host/wippy/hosts/sockets/tcp.go @@ -41,6 +41,23 @@ func (h *TCPHost) AsyncFunctions() []string { } // IPSocketAddress represents an IP address and port. +// The canonical ABI folds a host method's results into one value, and the +// binder accepts either an exact arity match or a (payload, error) pair it can +// fold. A method that returns its payload as several bare values matches +// neither, so it can never bind: `result count mismatch: expected 1, got 3`. +// These records carry the payloads that used to be returned loose, the same way +// IPSocketAddress already does for the address getters. +type TCPStreams struct { + Input uint32 + Output uint32 +} + +type TCPAccepted struct { + Socket uint32 + Input uint32 + Output uint32 +} + type IPSocketAddress struct { Address string Port uint16 @@ -198,23 +215,23 @@ func (h *TCPHost) MethodTCPSocketStartConnect(ctx context.Context, self uint32, } // [method]tcp-socket.finish-connect -func (h *TCPHost) MethodTCPSocketFinishConnect(_ context.Context, self uint32) (uint32, uint32, *NetworkError) { +func (h *TCPHost) MethodTCPSocketFinishConnect(_ context.Context, self uint32) (*TCPStreams, *NetworkError) { socket, err := h.getSocket(self) if err != nil { - return 0, 0, err + return nil, err } if socket.State() != preview2.TCPStateConnectInProgress { if socket.State() == preview2.TCPStateUnbound || socket.State() == preview2.TCPStateBound { - return 0, 0, &NetworkError{Code: NetworkErrorNotInProgress} + return nil, &NetworkError{Code: NetworkErrorNotInProgress} } - return 0, 0, &NetworkError{Code: NetworkErrorInvalidState} + return nil, &NetworkError{Code: NetworkErrorInvalidState} } if pendingErr := socket.PendingError(); pendingErr != nil { socket.ClearPendingError() socket.SetState(preview2.TCPStateClosed) - return 0, 0, mapNetError(pendingErr) + return nil, mapNetError(pendingErr) } socket.SetState(preview2.TCPStateConnected) @@ -227,7 +244,7 @@ func (h *TCPHost) MethodTCPSocketFinishConnect(_ context.Context, self uint32) ( socket.SetStreamHandles(inputHandle, outputHandle) - return inputHandle, outputHandle, nil + return &TCPStreams{Input: inputHandle, Output: outputHandle}, nil } // [method]tcp-socket.start-listen @@ -330,7 +347,7 @@ func (h *TCPHost) MethodTCPSocketFinishListen(_ context.Context, self uint32) *N } // [method]tcp-socket.accept -func (h *TCPHost) MethodTCPSocketAccept(ctx context.Context, self uint32) (uint32, uint32, uint32, *NetworkError) { +func (h *TCPHost) MethodTCPSocketAccept(ctx context.Context, self uint32) (*TCPAccepted, *NetworkError) { async := wasmengine.GetAsyncify(ctx) if async != nil && async.IsRewinding(ctx) { @@ -352,16 +369,16 @@ func (h *TCPHost) MethodTCPSocketAccept(ctx context.Context, self uint32) (uint3 acceptResult, ok := data.(*socketapi.AcceptResult) if !ok || acceptResult == nil { closeAsyncSocketResult(data) - return 0, 0, 0, &NetworkError{Code: NetworkErrorInvalidArgument} + return nil, &NetworkError{Code: NetworkErrorInvalidArgument} } if acceptResult.Err != nil { - return 0, 0, 0, mapNetError(acceptResult.Err) + return nil, mapNetError(acceptResult.Err) } socket, err := h.getSocket(self) if err != nil { _ = acceptResult.Conn.Close() - return 0, 0, 0, err + return nil, err } newSocket := preview2.NewTCPSocketResource(socket.Family()) @@ -385,21 +402,21 @@ func (h *TCPHost) MethodTCPSocketAccept(ctx context.Context, self uint32) (uint3 newSocket.SetStreamHandles(inputHandle, outputHandle) - return socketHandle, inputHandle, outputHandle, nil + return &TCPAccepted{Socket: socketHandle, Input: inputHandle, Output: outputHandle}, nil } socket, err := h.getSocket(self) if err != nil { - return 0, 0, 0, err + return nil, err } if socket.State() != preview2.TCPStateListening { - return 0, 0, 0, &NetworkError{Code: NetworkErrorInvalidState} + return nil, &NetworkError{Code: NetworkErrorInvalidState} } netListener, ok := socket.Listener().(net.Listener) if !ok { - return 0, 0, 0, &NetworkError{Code: NetworkErrorInvalidState} + return nil, &NetworkError{Code: NetworkErrorInvalidState} } op := &acceptPendingOp{cmd: &socketapi.AcceptCmd{Listener: netListener}} @@ -412,7 +429,7 @@ func (h *TCPHost) MethodTCPSocketAccept(ctx context.Context, self uint32) (uint3 panic(fmt.Errorf("tcp accept suspend: %w", suspendErr)) } - return 0, 0, 0, nil + return nil, nil } // [method]tcp-socket.shutdown diff --git a/runtime/wasm/host/wippy/hosts/sockets/tcp_test.go b/runtime/wasm/host/wippy/hosts/sockets/tcp_test.go index fcf5273b2..3884e4607 100644 --- a/runtime/wasm/host/wippy/hosts/sockets/tcp_test.go +++ b/runtime/wasm/host/wippy/hosts/sockets/tcp_test.go @@ -141,10 +141,10 @@ func TestS03TCPAcceptRejectsWrongAsyncType(t *testing.T) { t.Cleanup(func() { _ = right.Close() }) ctx := rewindContext(t, &socketapi.ConnectResult{Conn: carried}) - socketHandle, inputHandle, outputHandle, err := host.MethodTCPSocketAccept(ctx, handle) + accepted, err := host.MethodTCPSocketAccept(ctx, handle) requireNetworkError(t, err, NetworkErrorInvalidArgument) - if socketHandle != 0 || inputHandle != 0 || outputHandle != 0 { - t.Fatalf("rejected accept handles = (%d, %d, %d), want zero", socketHandle, inputHandle, outputHandle) + if accepted != nil { + t.Fatalf("rejected accept handles = %+v, want none", accepted) } if carried.closes.Load() != 1 { t.Fatalf("unadopted connection close count = %d, want 1", carried.closes.Load()) @@ -188,10 +188,10 @@ func TestS07TCPFinishConnectFailureClosesState(t *testing.T) { if err := host.MethodTCPSocketStartConnect(ctx, handle, 0, IPSocketAddress{}); err != nil { t.Fatalf("resume failed connect: %v", err) } - inputHandle, outputHandle, err := host.MethodTCPSocketFinishConnect(context.Background(), handle) + streams, err := host.MethodTCPSocketFinishConnect(context.Background(), handle) requireNetworkError(t, err, NetworkErrorConnectionRefused) - if inputHandle != 0 || outputHandle != 0 { - t.Fatalf("failure stream handles = (%d, %d), want zero", inputHandle, outputHandle) + if streams != nil { + t.Fatalf("failure stream handles = %+v, want none", streams) } if socket.PendingError() != nil || socket.State() != preview2.TCPStateClosed { t.Fatalf("failure state = %d, pending error = %v", socket.State(), socket.PendingError()) @@ -223,10 +223,14 @@ func TestS08TCPFinishConnectAdoptsStreams(t *testing.T) { if err := host.MethodTCPSocketStartConnect(ctx, handle, 0, IPSocketAddress{}); err != nil { t.Fatalf("resume successful connect: %v", err) } - inputHandle, outputHandle, networkErr := host.MethodTCPSocketFinishConnect(context.Background(), handle) + streams, networkErr := host.MethodTCPSocketFinishConnect(context.Background(), handle) if networkErr != nil { t.Fatalf("finish connect: %v", networkErr) } + if streams == nil { + t.Fatalf("finish connect returned no streams") + } + inputHandle, outputHandle := streams.Input, streams.Output if inputHandle == 0 || outputHandle == 0 || inputHandle == outputHandle { t.Fatalf("stream handles = (%d, %d), want distinct nonzero handles", inputHandle, outputHandle) } @@ -337,10 +341,14 @@ func TestS10TCPListenAcceptDropOwnership(t *testing.T) { acceptedConn := &closeCountingConn{Conn: accepted} ctx := rewindContext(t, &socketapi.AcceptResult{Conn: acceptedConn}) - childHandle, inputHandle, outputHandle, networkErr := host.MethodTCPSocketAccept(ctx, parentHandle) + acceptedHandles, networkErr := host.MethodTCPSocketAccept(ctx, parentHandle) if networkErr != nil { t.Fatalf("resume accept: %v", networkErr) } + if acceptedHandles == nil { + t.Fatalf("resume accept returned nothing") + } + childHandle, inputHandle, outputHandle := acceptedHandles.Socket, acceptedHandles.Input, acceptedHandles.Output if childHandle == 0 || inputHandle == 0 || outputHandle == 0 { t.Fatalf("accepted handles = (%d, %d, %d), want nonzero", childHandle, inputHandle, outputHandle) } From ae14cebfc3087c79a37f6584c47290377192d2e6 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Sun, 30 Aug 2026 19:57:03 +0400 Subject: [PATCH 2/2] wasm sockets: cover the binding with a real component Loads a Go-authored WASI Preview 2 component that imports finish-connect and fails if any sockets method cannot bind. Before the record change this failed with 'result count mismatch: expected 1, got 3'. --- .../runtime/wasm/sockets_bind_test.go | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 boot/components/runtime/wasm/sockets_bind_test.go diff --git a/boot/components/runtime/wasm/sockets_bind_test.go b/boot/components/runtime/wasm/sockets_bind_test.go new file mode 100644 index 000000000..afda72c6d --- /dev/null +++ b/boot/components/runtime/wasm/sockets_bind_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 + +package wasm + +import ( + "context" + "os" + "testing" + + "github.com/wippyai/runtime/api/registry" + wasmcomponent "github.com/wippyai/runtime/runtime/wasm/component" + wasmrt "github.com/wippyai/wasm-runtime/runtime" + "go.uber.org/zap" +) + +// The sockets host binds only if every one of its methods matches what the +// canonical-ABI lowering expects. finish-connect and accept used to return +// their handles as several bare values, which no guest could ever bind, so +// wasi:sockets TCP was dead end to end. This loads a real Go-authored +// component that imports those methods and fails if any of them cannot bind. +// +// The component is built from packages/xepozz/smtp/wasm-go in the neuro-brat +// deployment; point WASM_SOCKETS_PROBE at it to run this. +func TestWASISocketsProfileBindsAGoComponent(t *testing.T) { + path := os.Getenv("WASM_SOCKETS_PROBE") + if path == "" { + t.Skip("set WASM_SOCKETS_PROBE to a component importing wasi:sockets") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read probe: %v", err) + } + + ctx := context.Background() + rt, err := wasmrt.New(ctx) + if err != nil { + t.Fatalf("new runtime: %v", err) + } + defer func() { _ = rt.Close(ctx) }() + + hosts := wasmcomponent.NewHostRegistry() + if err := hosts.RegisterProfiles(DefaultHostProfiles(zap.NewNop(), nil)...); err != nil { + t.Fatalf("register profiles: %v", err) + } + + imports := []registry.ID{ + registry.ParseID("wasi:io"), + registry.ParseID("wasi:cli"), + registry.ParseID("wasi:clocks"), + registry.ParseID("wasi:filesystem"), + registry.ParseID("wasi:random"), + registry.ParseID("wasi:sockets"), + } + if err := hosts.EnsureImports(ctx, rt, imports, true); err != nil { + t.Fatalf("bind hosts: %v", err) + } + + module, err := rt.LoadComponent(ctx, data) + if err != nil { + t.Fatalf("load component: %v", err) + } + if err := module.Compile(ctx); err != nil { + t.Fatalf("compile component: %v", err) + } +}