Postie: A safe alternative to RemoteFunctions with a time-out

Introduction

Postie is a module that provides a safe alternative to RemoteFunctions, offering a time-out parameter when invoking another machine. The main benefit of this is that you avoid the issues that come with invoking the client with a RemoteFunction:

  • If the client throws an error, the server will throw the error too.
  • If the client disconnects while it’s being invoked, the InvokeClient call will error.
  • If the client never returns a value, the server will hang forever.

Postie solves all three of these problems by replacing one RemoteFunction invocation with two RemoteEvent firings.


Links


Interface

Postie.invokeClient( // yields, server-side
	player: Player,
	id: string,
	timeOut: number,
	...data: any
) => didRespond: boolean, ...response: any

Invoke player with sent data. Invocation identified by id. Yield until timeOut (given in seconds) is reached and return false, or a response is received back from the client and return true plus the data returned from the client. If the invocation reaches the client, but the client doesn’t have a corresponding callback, return before timeOut regardless but return false.

Postie.invokeServer( // yields, client-side
	id: string,
	timeOut: number,
	...data: any
) => didRespond: boolean, ...response: any

Invoke the server with sent data. Invocation identified by id. Yield until timeOut (given in seconds) is reached and return false, or a response is received back from the server and return true plus the data returned from the server. If the invocation reaches the server, but the server doesn’t have a corresponding callback, return before timeOut regardless but return false.

Postie.setCallback(
	id: string,
	callback?: (...data: any) -> ...response: any
)

Set the callback that is invoked when an invocation identified by id is sent. Data sent with the invocation are passed to the callback. If on the server, the player who invoked is implicitly received as the first argument. If nil is passed instead of a function, the current callback will just be removed.

Postie.getCallback(
	id: string
) => callback?: (...data: any) -> ...response: any

Return the callback corresponding with id.


Usage

Example 1 - server to client

Server

local Postie = require(the.path.to.Postie)

local function getBallsOnScreen(player)
	-- We request the amount of balls on the client's screen with a time-out of 5 seconds.
	local didRespond, amountOfBalls = Postie.invokeClient(player, "get-objects-on-screen", 5, "balls")
	if didRespond then -- We check for the time-out (or the client has no callback registered).
		-- A malicious client can always modify the returned data!
		if typeof(amountOfBalls) == "number" then
			return true, amountOfBalls
		end
	end
	return false
end

Client

local Postie = require(the.path.to.Postie)

Postie.setCallback("get-objects-on-screen", function(objectType)
	return amountOnScreenByObjectType[objectType]
end)

Example 2 - client to server

Server

local Postie = require(the.path.to.Postie)

Postie.setCallback("get-coins", function(player)
	return coinsByPlayer[player]
end)

Client

local Postie = require(the.path.to.Postie)

local function getCoins()
	-- We request how many coins we have with a time-out of 5 seconds.
	return Postie.invokeServer("get-coins", 5)
end

Additional Notes

  • The module is really just a wrapper for RemoteEvents and does not use RemoteFunctions under the hood.
  • Unlike other approaches to the client invocation problem, the module does not cause any memory leakage; the threads and functions it creates through usage are appropriately garbage collected.
66 Likes

I’ve just updated the module to fix a bug that causes different invocations of the same ID to fail to receive the correctly corresponding returned values in some cases.

1 Like

I’ve just updated the module to fix a pretty serious bug where the complementary InvokeServer function was never successful (sorry! :sweat_smile:). I’ve also switched over to using UUIDs instead of an incrementing integer to identify specific invocations, used BindableEvents & fast spawn in the place of coroutines to avoid obscuring errors, and fixed a few other small issues.

You can get this update by taking the module from the Roblox website again.

1 Like

Hey there! I had an issue with this API and I’ve found a fix for it.
I wanted to return multiple arguments from my callback function, eg:

        Server:
            local postie = require(postieObj)

            postie.SetCallback("GetCoins", function(player)
                return "one", "two"
            end)

        Client:
            local postie = require(postieObj)

            local function getCoins()
                return postie.InvokeServer("GetCoins", 5)
            end

I found it would only get the first arg. I fixed it by updating this line (197)
from: received:FireClient(player, uuid, callback and callback(player, …))
to:

	local args = table.pack(callback(player, ...))
        received:FireClient(player, uuid, table.unpack(args, 1, args.n))
1 Like

Thanks, I’ve updated the module to fix this bug.

3 Likes

Does this clear out unused connections to prevent Memory Leaks?

Or do you have to do it yourself?

I was looking through the code for this and noticed that table.remove(listeners, pos) is being used to remove old functions when a request is completed or timed out. At first, that made sense, but now I realized that can cause requests to get “lost”.

local pos = #listeners + 1
-- get uuid
local uuid = httpService:GenerateGUID(false)
-- await signal from client
listeners[pos] = function(playerWhoFired, signalUuid, ...)
	if not (playerWhoFired == player and signalUuid == uuid) then return false end
	isResumed = true
	table.remove(listeners, pos) -- Now that the request is completed, the function is removed from the list of listeners
	bindable:Fire(true, ...)
	return true
end

The issue with this is that table.remove() moves all other items in the table down one position. The following simplified version demonstrates the issue.

local listeners = {} -- Table of active listeners

local aPos = #listeners+1 -- #listeners = 0 + 1 = 1
listeners[aPos] = "A" -- Listener 'A' is created and put at index 1

local bPos = #listeners+1 -- #listeners = 1 + 1 = 2
listeners[bPos] = "B" -- Listener 'B' is created and put at index 2

print(listeners) --> {[1] = 'A', [2] = 'B'}

table.remove(listeners, aPos) -- Listener 'A' finishes and is removed

print(listeners) --> {[1] = 'B'}

-- Note how 'B' is now at index 1 but bPos is still 2

local cPos = #listeners+1 -- #listeners = 0 + 1 = 2
listeners[cPos] = "C" -- Listener 'C' is created and put at index 2

print(listeners) --> {[1] = 'B', [2] = 'C'}

So now we’re in a position where both bPos and cPos are equal to 2 but listener B isn’t at index 2 anymore. If listener B were to time out or complete at this point, table.remove(listeners, bPos) would remove listener C instead of listener B, causing listener C to go unresolved.

If you’d rather experience the issue in-game, this is an .rbxl file that creates the above situation with Postie. Baseplate.rbxl (24.6 KB)

This can be fixed by using the UUID as the index rather than the count of other active listeners. This modified version of Postie does just that and fixes the issue described. Fixed Postie.rbxm (3.4 KB)

5 Likes

:tada: Postie has been updated to version 1.1.0 (prior to this update I wasn’t numbering versions):

  • Static typing;

  • A fix to a bug where an invocation was reported as having a successful response if the other machine received the request but had no corresponding callback. Invoking a machine with an ID that the machine has no callback corresponding to will now result in false being returned instead of true;

  • Usage of coroutine and task libraries instead of BindableEvent;

  • And faster handling of requests (which will likely never be noticeable – Postie is just indexing a dictionary instead of iterating over a list in the new update).

I recommend updating even if you don’t care about the changes above, as, depending on how old your version is, there may be some critical bugs hanging around that you never updated Postie to get rid of (scroll up in this thread to read about them).

2 Likes

Holy crap! I really love this module it could prevent exploit so I give it 10/10 :pog:

As good as this module is, I wouldn’t recommend using Postie just because you think it has security benefits.

maybe you’re right I’m just gonna use it for remote function replacement

Just to clarify, Postie doesn’t offer any security benefits over RemoteFunctions besides the three bullet points I listed in the OP (and these three bullets could also occur with a legitimate client). Malicious clients can still respond to invocations with bad data, or invoke the server at any time with bad data.

1 Like

Sorry, I didn’t see this until now, but you don’t make any event connections with Postie. If you’re talking about whether or not callbacks are automatically unset, they are not (this wouldn’t make sense to do IMO). You should unset callbacks yourself if you want this to happen.