Ctrl K
ComunidadGuías…

bldr/resource/cross-runtime_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package resource_test

import (
	"bufio"
	"bytes"
	"context"
	"encoding/binary"
	"errors"
	"io"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/aperturerobotics/starpc/srpc"
	"github.com/s4wave/spacewave/bldr/resource"
	resource_client "github.com/s4wave/spacewave/bldr/resource/client"
	resource_server "github.com/s4wave/spacewave/bldr/resource/server"
)

const crossRuntimeFixtureTimeout = 20 * time.Second

type crossRuntimeProcess struct {
	cmd   *exec.Cmd
	input io.WriteCloser

	// outputMtx guards output and stderr, which os/exec and the stdout
	// scanner write while failure reporting reads them.
	outputMtx sync.Mutex
	output    []string
	stderr    bytes.Buffer
	lines     chan string
	done      chan struct{}
	err       error
}

func startCrossRuntimeProcess(
	t *testing.T,
	ctx context.Context,
	name string,
	args ...string,
) *crossRuntimeProcess {
	// Resolve the repository root and build the subprocess command.
	t.Helper()
	root, err := filepath.Abs("../..")
	if err != nil {
		t.Fatalf("resolve repository root: %v", err)
	}
	// Without node_modules, bun auto-installs packages outside the lockfile,
	// and the fixtures fail inside mismatched generated code.
	if name == "bun" {
		if _, err := os.Stat(filepath.Join(root, "node_modules")); err != nil {
			t.Fatalf("bun fixtures need the lockfile's packages: run bun install --frozen-lockfile in %s", root)
		}
	}
	command := exec.CommandContext(ctx, name, args...)
	command.Dir = root
	command.Env = os.Environ()

	// Open the subprocess stdin and stdout pipes.
	input, err := command.StdinPipe()
	if err != nil {
		t.Fatalf("%s stdin: %v", name, err)
	}
	stdout, err := command.StdoutPipe()
	if err != nil {
		t.Fatalf("%s stdout: %v", name, err)
	}

	// Start the subprocess with stderr captured under the output lock.
	process := &crossRuntimeProcess{
		cmd:   command,
		input: input,
		lines: make(chan string, 64),
		done:  make(chan struct{}),
	}
	command.Stderr = crossRuntimeStderr{process: process}
	if err := command.Start(); err != nil {
		t.Fatalf("start %s: %v", name, err)
	}

	// Scan stdout lines into the process output and line channel.
	scanDone := make(chan struct{})
	go func() {
		defer close(scanDone)
		scanner := bufio.NewScanner(stdout)
		for scanner.Scan() {
			line := scanner.Text()
			process.outputMtx.Lock()
			process.output = append(process.output, line)
			process.outputMtx.Unlock()
			process.lines <- line
		}
	}()

	// Wait for the subprocess and close the done channel after scanning ends.
	go func() {
		process.err = command.Wait()
		<-scanDone
		close(process.done)
	}()
	return process
}

// crossRuntimeStderr appends subprocess stderr under the output lock.
type crossRuntimeStderr struct {
	process *crossRuntimeProcess
}

// Write appends one stderr chunk.
func (w crossRuntimeStderr) Write(data []byte) (int, error) {
	w.process.outputMtx.Lock()
	defer w.process.outputMtx.Unlock()
	return w.process.stderr.Write(data)
}

func (p *crossRuntimeProcess) send(t *testing.T, command string) {
	t.Helper()
	if _, err := io.WriteString(p.input, command+"\n"); err != nil {
		t.Fatalf("send %q to %s: %v\n%s", command, p.cmd.Path, err, p.logs())
	}
}

func (p *crossRuntimeProcess) waitLine(
	t *testing.T,
	ctx context.Context,
	want string,
) string {
	t.Helper()
	for {
		select {
		case line := <-p.lines:
			if line == want || strings.HasPrefix(line, want) {
				return line
			}
		case <-p.done:
			t.Fatalf("%s exited before %q: %v\n%s", p.cmd.Path, want, p.err, p.logs())
		case <-ctx.Done():
			t.Fatalf("timed out waiting for %q from %s\n%s", want, p.cmd.Path, p.logs())
		}
	}
}

func (p *crossRuntimeProcess) waitExit(t *testing.T, ctx context.Context) {
	t.Helper()
	select {
	case <-p.done:
		if p.err != nil {
			t.Fatalf("%s failed: %v\n%s", p.cmd.Path, p.err, p.logs())
		}
	case <-ctx.Done():
		t.Fatalf("timed out waiting for %s\n%s", p.cmd.Path, p.logs())
	}
}

func (p *crossRuntimeProcess) logs() string {
	// Join the captured stdout and stderr under the output lock.
	p.outputMtx.Lock()
	defer p.outputMtx.Unlock()
	logs := strings.Join(p.output, "\n")
	if p.stderr.Len() != 0 {
		logs += "\n" + p.stderr.String()
	}
	return logs
}

func TestPythonClientResourceLifecycleAgainstTypeScriptServer(t *testing.T) {
	// Start the TypeScript server subprocess with a bounded context.
	ctx, cancel := context.WithTimeout(t.Context(), crossRuntimeFixtureTimeout)
	defer cancel()
	server := startCrossRuntimeProcess(
		t,
		ctx,
		"bun",
		"run",
		"./bldr/resource/testdata/cross-runtime-ts-core-server.ts",
		"127.0.0.1:0",
	)
	ready := server.waitLine(t, ctx, "READY ")
	address := strings.TrimPrefix(ready, "READY ")
	client := startCrossRuntimeProcess(
		t,
		ctx,
		"uv",
		"run",
		"python",
		"bldr/resource/testdata/cross-runtime-python-client.py",
		address,
	)

	// Drive the Python client through adopt and release against the TypeScript server.
	server.waitLine(t, ctx, "ADOPT_ACK_HELD")
	server.send(t, "ALLOW_ADOPT")
	server.waitLine(t, ctx, "HANDLER_FINALLY")
	server.waitLine(t, ctx, "RELEASE_COMPLETE")
	server.waitLine(t, ctx, "HANDLER_FINALLY")

	// Invalidate the TypeScript server and wait for both runtimes to drain.
	client.waitLine(t, ctx, "PY_CLIENT_READY_TO_INVALIDATE")
	server.send(t, "INVALIDATE")
	server.waitLine(t, ctx, "TS_SERVER_OWNER_ZERO")
	client.waitLine(t, ctx, "PY_CLIENT_OWNER_ZERO")
	client.waitExit(t, ctx)
	server.waitExit(t, ctx)
}

func TestTypeScriptClientResourceLifecycleAgainstPythonServer(t *testing.T) {
	// Start the Python server subprocess with a bounded context.
	ctx, cancel := context.WithTimeout(t.Context(), crossRuntimeFixtureTimeout)
	defer cancel()
	server := startCrossRuntimeProcess(
		t,
		ctx,
		"uv",
		"run",
		"python",
		"bldr/resource/testdata/cross-runtime-python-server.py",
		"--listen",
		"127.0.0.1:0",
	)

	// Start the TypeScript client subprocess against the Python server address.
	ready := server.waitLine(t, ctx, "READY ")
	client := startCrossRuntimeProcess(
		t,
		ctx,
		"bun",
		"run",
		"./bldr/resource/testdata/cross-runtime-ts-core-client.ts",
		strings.TrimPrefix(ready, "READY "),
	)

	// Drive the TypeScript client through adopt and release against the Python server.
	server.waitLine(t, ctx, "ADOPT_ACK_HELD")
	server.send(t, "ALLOW_ADOPT")
	server.waitLine(t, ctx, "HANDLER_FINALLY")
	server.waitLine(t, ctx, "HANDLER_FINALLY")
	server.waitLine(t, ctx, "RELEASE_ENTERED")
	server.send(t, "ALLOW_RELEASE")
	server.waitLine(t, ctx, "RELEASE_COMPLETE")

	// Invalidate the Python server and wait for both runtimes to drain.
	client.waitLine(t, ctx, "TS_CLIENT_READY_TO_INVALIDATE")
	server.send(t, "INVALIDATE")
	server.waitLine(t, ctx, "PY_SERVER_OWNER_ZERO")
	client.waitLine(t, ctx, "TS_CLIENT_OWNER_ZERO")
	client.waitExit(t, ctx)
	server.waitExit(t, ctx)
}

func TestGoClientResourceLifecycleAgainstPythonServer(t *testing.T) {
	// Start the Python server subprocess with a bounded context.
	ctx, cancel := context.WithTimeout(t.Context(), crossRuntimeFixtureTimeout)
	defer cancel()
	server := startCrossRuntimeProcess(
		t,
		ctx,
		"uv",
		"run",
		"python",
		"bldr/resource/testdata/cross-runtime-python-server.py",
		"--listen",
		"127.0.0.1:0",
	)

	// Build the Go ResourceClient against the Python server listener.
	ready := server.waitLine(t, ctx, "READY ")
	service := resource.NewSRPCResourceServiceClient(
		newCrossRuntimeTCPClient(strings.TrimPrefix(ready, "READY ")),
	)
	client, err := resource_client.NewClient(ctx, service)
	if err != nil {
		t.Fatalf("start Go ResourceClient: %v", err)
	}
	root := client.AccessRootResource()
	rootClient, err := root.GetClient()
	if err != nil {
		t.Fatalf("get Go root client: %v", err)
	}

	// Open the Go root resource and start the nested Spawn call in the background.
	spawnResult := make(chan struct {
		data []byte
		err  error
	}, 1)
	go func() {
		data, callErr := crossRuntimeCall(
			ctx,
			rootClient,
			"test.Root",
			"Spawn",
			[]byte("spawn"),
		)
		spawnResult <- struct {
			data []byte
			err  error
		}{data, callErr}
	}()

	// Hold the delayed acknowledgement and confirm the Spawn call stays blocked.
	server.waitLine(t, ctx, "ADOPT_ACK_HELD")
	select {
	case result := <-spawnResult:
		t.Fatalf("Go nested route opened before delayed acknowledgement: %q/%v", result.data, result.err)
	default:
	}

	// Allow the adopt and collect the spawned child reference.
	server.send(t, "ALLOW_ADOPT")
	var childData []byte
	select {
	case result := <-spawnResult:
		if result.err != nil {
			t.Fatalf("spawn Python child: %v", result.err)
		}
		childData = result.data
	case <-ctx.Done():
		t.Fatal("timed out opening Go root route after acknowledgement")
	}

	// Validate the spawned child ID length and value.
	if len(childData) != 4 {
		t.Fatalf("child ID length = %d, want 4", len(childData))
	}
	childID := binary.BigEndian.Uint32(childData)
	if childID == 0 {
		t.Fatal("Spawn returned an empty child ID")
	}

	// Stream through the Python child resource and check the response.
	child := client.CreateResourceReference(childID)
	childClient, err := child.GetClient()
	if err != nil {
		t.Fatalf("get child client: %v", err)
	}
	data, err := crossRuntimeCall(ctx, childClient, "test.Child", "Stream", []byte("stream"))
	if err != nil {
		t.Fatalf("stream Python child: %v", err)
	}
	if string(data) != "later" {
		t.Fatalf("stream data = %q, want later", data)
	}

	// Open a cancellable child stream and verify the active data before canceling it.
	canceled, err := childClient.NewStream(
		ctx,
		"test.Child",
		"Block",
		srpc.NewRawMessage([]byte("block"), true),
	)
	if err != nil {
		t.Fatalf("open cancellable child stream: %v", err)
	}
	active := srpc.NewRawMessage(nil, true)
	if err := canceled.MsgRecv(active); err != nil {
		t.Fatalf("receive cancellable child data: %v", err)
	}
	if string(active.GetData()) != "active" {
		t.Fatalf("cancellable data = %q, want active", active.GetData())
	}
	if err := canceled.Close(); err != nil {
		t.Fatalf("cancel child stream: %v", err)
	}
	server.waitLine(t, ctx, "HANDLER_FINALLY")

	// Open a second child stream and release the child while it is active.
	released, err := childClient.NewStream(
		ctx,
		"test.Child",
		"Block",
		srpc.NewRawMessage([]byte("block"), true),
	)
	if err != nil {
		t.Fatalf("open releasable child stream: %v", err)
	}
	if err := released.MsgRecv(active); err != nil {
		t.Fatalf("receive releasable child data: %v", err)
	}
	child.Release()
	server.waitLine(t, ctx, "HANDLER_FINALLY")
	server.waitLine(t, ctx, "RELEASE_ENTERED")

	// Start a root Echo call that must stay blocked behind the child release barrier.
	echoResult := make(chan struct {
		data []byte
		err  error
	}, 1)
	go func() {
		data, callErr := crossRuntimeCall(
			ctx,
			rootClient,
			"test.Root",
			"Echo",
			[]byte("after-release"),
		)
		echoResult <- struct {
			data []byte
			err  error
		}{data, callErr}
	}()
	select {
	case result := <-echoResult:
		t.Fatalf("root route passed the child release barrier: %q/%v", result.data, result.err)
	default:
	}

	// Release the child barrier and verify the root Echo completes afterwards.
	server.send(t, "ALLOW_RELEASE")
	server.waitLine(t, ctx, "RELEASE_COMPLETE")
	select {
	case result := <-echoResult:
		if result.err != nil {
			t.Fatalf("route root after child release: %v", result.err)
		}
		if string(result.data) != "after-release" {
			t.Fatalf("root response = %q, want after-release", result.data)
		}
	case <-ctx.Done():
		t.Fatal("timed out routing root after child release")
	}
	if err := released.Close(); err != nil && !errors.Is(err, context.Canceled) {
		t.Fatalf("close released child stream: %v", err)
	}

	// Release the root and verify a fresh reference reuses the retained route.
	root.Release()
	reused := client.AccessRootResource()
	reusedClient, err := reused.GetClient()
	if err != nil {
		t.Fatalf("get retained root client: %v", err)
	}
	data, err = crossRuntimeCall(ctx, reusedClient, "test.Root", "Echo", []byte("reused"))
	if err != nil {
		t.Fatalf("route retained root: %v", err)
	}
	if string(data) != "reused" {
		t.Fatalf("retained root response = %q, want reused", data)
	}
	reused.Release()

	// Invalidate the server generation and confirm the client and stale reference fail.
	server.send(t, "INVALIDATE")
	select {
	case <-client.Done():
	case <-ctx.Done():
		t.Fatal("timed out invalidating Go ResourceClient generation")
	}
	stale := client.AccessRootResource() //nolint:lostresource // The server has invalidated this generation; the test verifies the returned reference is already released.
	if _, err := stale.GetClient(); !errors.Is(err, resource.ErrResourceOrClientReleased) {
		t.Fatalf("invalidated root error = %v, want %v", err, resource.ErrResourceOrClientReleased)
	}
	server.waitLine(t, ctx, "PY_SERVER_OWNER_ZERO")
	server.waitExit(t, ctx)
}

func TestGoAttachedResourceTwoHopLifecycleAgainstTypeScriptServer(t *testing.T) {
	// Start the TypeScript attached-server subprocess with a bounded context.
	ctx, cancel := context.WithTimeout(t.Context(), crossRuntimeFixtureTimeout)
	defer cancel()
	server := startCrossRuntimeProcess(
		t,
		ctx,
		"bun",
		"run",
		"./bldr/resource/testdata/cross-runtime-ts-core-server.ts",
		"127.0.0.1:0",
		"attached",
	)

	// Build the Go ResourceClient against the TypeScript server listener.
	ready := server.waitLine(t, ctx, "READY ")
	service := resource.NewSRPCResourceServiceClient(
		newCrossRuntimeTCPClient(strings.TrimPrefix(ready, "READY ")),
	)
	client, err := resource_client.NewClient(ctx, service)
	if err != nil {
		t.Fatalf("start Go ResourceClient: %v", err)
	}

	// Prepare the attached child channels and the fake Engine invoker.
	var childReleaseCount atomic.Int32
	var childAbortCount atomic.Int32
	childIDCh := make(chan uint32, 1)
	blockActive := make(chan struct{}, 1)
	childAborted := make(chan struct{}, 1)
	childReleased := make(chan struct{}, 1)
	attachedMux := srpc.InvokerFunc(func(serviceID, methodID string, strm srpc.Stream) (bool, error) {
		// Handle only the attached Engine Construct method.
		if serviceID != "test.AttachedEngine" || methodID != "Construct" {
			return false, nil
		}

		// Receive and validate the construct request payload.
		request := srpc.NewRawMessage(nil, true)
		if err := strm.MsgRecv(request); err != nil {
			return true, err
		}
		if string(request.GetData()) != "construct" {
			return true, errors.New("attached construct request mismatch")
		}

		// Add the fake attached child resource under the requesting client owner.
		owner, err := resource_server.MustGetResourceClientContext(strm.Context())
		if err != nil {
			return true, err
		}
		childID, err := owner.AddResource(srpc.InvokerFunc(func(serviceID, methodID string, strm srpc.Stream) (bool, error) {
			// Handle only the attached child service.
			if serviceID != "test.AttachedChild" {
				return false, nil
			}
			// Receive the child request payload before dispatching the method.
			request := srpc.NewRawMessage(nil, true)
			if err := strm.MsgRecv(request); err != nil {
				return true, err
			}
			// Dispatch the child Invoke and Block methods.
			switch methodID {
			case "Invoke":
				if string(request.GetData()) != "invoke" {
					return true, errors.New("attached child invoke request mismatch")
				}
				return true, strm.MsgSend(srpc.NewRawMessage([]byte("nested-success"), true))
			case "Block":
				if string(request.GetData()) != "block" {
					return true, errors.New("attached child block request mismatch")
				}
				// Send the active frame and hold until the stream context aborts.
				if err := strm.MsgSend(srpc.NewRawMessage([]byte("active"), true)); err != nil {
					return true, err
				}
				blockActive <- struct{}{}
				<-strm.Context().Done()
				childAbortCount.Add(1)
				childAborted <- struct{}{}
				return true, context.Cause(strm.Context())
			default:
				return false, nil
			}
		}), func() {
			childReleaseCount.Add(1)
			childReleased <- struct{}{}
		})
		// Report the child ID to the test and answer the construct call.
		if err != nil {
			return true, err
		}
		childIDCh <- childID
		return true, strm.MsgSend(srpc.NewRawMessage(encodeCrossRuntimeID(childID), true))
	})

	// Attach the fake Engine tree to the client and validate the returned root ID.
	attachedRootID, err := client.AttachResourceTree(ctx, "fake-engine", attachedMux)
	if err != nil {
		t.Fatalf("attach fake Engine tree: %v\n%s", err, server.logs())
	}
	if attachedRootID == 0 {
		t.Fatal("AddAck returned an empty attached root ID")
	}

	// Open the TypeScript root resource and construct the attached object through it.
	root := client.AccessRootResource()
	rootClient, err := root.GetClient()
	if err != nil {
		t.Fatalf("get TypeScript root client: %v", err)
	}
	objectData, err := crossRuntimeCall(
		ctx,
		rootClient,
		"test.Root",
		"ConstructAttachedObject",
		encodeCrossRuntimeID(attachedRootID),
	)
	if err != nil {
		t.Fatalf("construct TypeScript ObjectType-like child: %v\n%s", err, server.logs())
	}

	// Match the TypeScript AddAck marker against the attached root ID.
	if len(objectData) != 4 {
		t.Fatalf("object ID length = %d, want 4", len(objectData))
	}
	objectID := binary.BigEndian.Uint32(objectData)
	addAck := server.waitLine(t, ctx, "ATTACHED_ADD_ACK ")
	if strings.TrimPrefix(addAck, "ATTACHED_ADD_ACK ") != strconv.FormatUint(uint64(attachedRootID), 10) {
		t.Fatalf("TypeScript AddAck ID marker = %q, Go AddAck ID = %d", addAck, attachedRootID)
	}

	// Match the child-added marker against the ID reported by the fake Engine.
	childMarker := server.waitLine(t, ctx, "ATTACHED_CHILD_ADDED ")
	var childID uint32
	select {
	case childID = <-childIDCh:
	case <-ctx.Done():
		t.Fatalf("timed out receiving attached child ID after %q", childMarker)
	}
	if strings.TrimPrefix(childMarker, "ATTACHED_CHILD_ADDED ") != strconv.FormatUint(uint64(childID), 10) {
		t.Fatalf("TypeScript child ID marker = %q, Go child ID = %d", childMarker, childID)
	}

	// Use the constructed object and confirm the nested child handled the call.
	object := client.CreateResourceReference(objectID)
	objectClient, err := object.GetClient()
	if err != nil {
		t.Fatalf("get constructed object client: %v", err)
	}
	data, err := crossRuntimeCall(ctx, objectClient, "test.AttachedObject", "Use", []byte("use"))
	if err != nil {
		t.Fatalf("use nested attached Go child after construction: %v\n%s", err, server.logs())
	}
	if string(data) != "nested-success" {
		t.Fatalf("nested response = %q, want nested-success", data)
	}

	// Verify the child stayed attached and the client generation stayed live.
	server.waitLine(t, ctx, "ATTACHED_USE_COMPLETE")
	if got := childReleaseCount.Load(); got != 0 {
		t.Fatalf("attached child detached before response: release count = %d", got)
	}
	server.send(t, "CHECK_LIVE")
	server.waitLine(t, ctx, "ATTACHED_LIVE_NO_CANCEL")
	select {
	case <-client.Done():
		t.Fatal("ResourceClient generation retired after successful two-hop call")
	default:
	}

	// Start the nested Block call and wait for the fake child to become active.
	blockResult := make(chan error, 1)
	go func() {
		_, callErr := crossRuntimeCall(ctx, objectClient, "test.AttachedObject", "Block", []byte("block"))
		blockResult <- callErr
	}()
	select {
	case <-blockActive:
	case <-ctx.Done():
		t.Fatalf("nested attached method did not become active\n%s", server.logs())
	}

	// Detach the attached child and confirm both sides observed the abort.
	server.send(t, "DETACH_ATTACHED_CHILD")
	server.waitLine(t, ctx, "ATTACHED_CHILD_DETACHED")
	select {
	case <-childReleased:
	case <-ctx.Done():
		t.Fatal("nested attached child detach was not acknowledged")
	}
	server.waitLine(t, ctx, "ATTACHED_BLOCK_ABORTED")
	select {
	case <-childAborted:
	case <-ctx.Done():
		t.Fatal("nested attached Go handler did not observe detach")
	}

	// Verify the detached call failed exactly once and the child released once.
	select {
	case err := <-blockResult:
		if err == nil {
			t.Fatal("detached nested attached method returned success")
		}
	case <-ctx.Done():
		t.Fatal("detached nested attached method did not return")
	}
	if got := childAbortCount.Load(); got != 1 {
		t.Fatalf("attached child abort count = %d, want 1", got)
	}
	if got := childReleaseCount.Load(); got != 1 {
		t.Fatalf("attached child release count = %d, want 1", got)
	}
	server.send(t, "CHECK_ABORT_ONCE")
	server.waitLine(t, ctx, "ATTACHED_ABORT_ONCE")

	// Release the object, root, and client, then confirm the generation ends.
	object.Release()
	root.Release()
	client.Release()
	select {
	case <-client.Done():
	case <-ctx.Done():
		t.Fatal("timed out ending Go ResourceClient generation")
	}
	if got := childReleaseCount.Load(); got != 1 {
		t.Fatalf("attached child release count after client Done = %d, want 1", got)
	}

	// Verify the TypeScript server drains to owner zero and exits cleanly.
	server.send(t, "CHECK_CLEAN")
	server.waitLine(t, ctx, "TS_SERVER_OWNER_ZERO")
	if got := childReleaseCount.Load(); got != 1 {
		t.Fatalf("attached child release count after TypeScript owner zero = %d, want 1", got)
	}
	server.waitExit(t, ctx)
	if got := childReleaseCount.Load(); got != 1 {
		t.Fatalf("attached child release count after server exit = %d, want 1", got)
	}
}

func encodeCrossRuntimeID(id uint32) []byte {
	data := make([]byte, 4)
	binary.BigEndian.PutUint32(data, id)
	return data
}

func newCrossRuntimeTCPClient(address string) srpc.Client {
	return srpc.NewClient(func(
		ctx context.Context,
		handler srpc.PacketDataHandler,
		closeHandler srpc.CloseHandler,
	) (srpc.PacketWriter, error) {
		// Dial the cross-runtime server and start its packet read pump.
		connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
		if err != nil {
			return nil, err
		}
		packets := srpc.NewPacketReadWriter(connection)
		go packets.ReadPump(handler, closeHandler)
		return packets, nil
	})
}

func crossRuntimeCall(
	ctx context.Context,
	client srpc.Client,
	service string,
	method string,
	data []byte,
) ([]byte, error) {
	response := srpc.NewRawMessage(nil, true)
	err := client.ExecCall(
		ctx,
		service,
		method,
		srpc.NewRawMessage(data, true),
		response,
	)
	return response.GetData(), err
}