Known Issues

There are no known bugs in the current code. What is left is the record: the sharp edges that are doing what they were written to do and catch people out anyway, and the things that used to be listed here as bugs and are now fixed.

Not bugs, but worth knowing

Five behaviours surprise people. Each one is deliberate, and each one has a plain way to work with it.

Approved on a destroyed carrier returns everyone

Destroy clears the handshake guard along with everything else, and Approved returns every candidate when there is no guard. So a destroyed guarded carrier answers with the full list.

Server
local mods = Pigeon.new("Moderation")

mods:UseHandshake(function(player, token)
	return isModerator(player, token)
end)

-- Once the moderators have handshaked.
print(#mods:Approved())   --> only the moderators

mods:Destroy()
print(#mods:Approved())   --> everyone in the server

Nothing leaks from this on its own, because a destroyed carrier cannot send. It matters if you use Approved to decide something other than a send. Check the list before you destroy the carrier, not after.

Two carriers on one name can hide events from each other's middleware

Incoming middleware sees every packet on its channel, except one: an event that a different carrier on the same name has bound, with either On or When. That carrier claims the packet, so the other one's chain never gets a look at it.

-- Both are on the "Chat" channel.
local a = Pigeon.new("Chat")
local b = Pigeon.new("Chat", { Timeout = 5 })

a:On("Message", handleMessage)

b:UseIncoming(function(direction, event, args)
	print(event)   -- never prints "Message"
end)

Keep the receiving side to one carrier per name and this cannot arise. Passing any options builds a fresh uncached carrier, which is the usual way to end up with two by accident. See Carriers and Middleware.

A handshake guard can be replaced but not removed

UseHandshake is the only way to set a guard, and there is no matching call to take one off. Installing a second guard replaces the first and closes the channel again, so everyone has to handshake once more.

Server
-- The nearest thing to removing a guard: one that admits anyone who asks.
mods:UseHandshake(function()
	return true
end)

Clients are not told when the guard changes. They find out by calling Handshake again, and until they do the channel carries nothing for them. Revoke shuts one player out without touching the guard, but it cannot take the guard off either. See Handshakes.

Only the last carrier to capture a staged table replicates it

A staged table holds one hook for reporting its writes. Capturing the same table on a second carrier replaces the first carrier's hook, so patches go out on the second carrier's channel from then on. Clients that took the table through the first carrier stop seeing updates.

Server
local scores = Pigeon.StagedTable({ red = 0 })

local lobby = Pigeon.new("Lobby")
local match = Pigeon.new("Match")

lobby:CaptureTable("scores", scores)
match:CaptureTable("scores", scores)   -- lobby stops replicating it

Capture each staged table on one carrier and leave it there. See Staged Tables.

Writing through a view whose table is gone raises an error

A nested view points at a slot, not at a table, so it survives the table in that slot being replaced. If the slot is emptied instead, there is nothing to write into. Reads answer nil, the length is 0 and a loop runs zero times, but a write errors rather than resurrecting a table nobody asked for.

Server
local staged = Pigeon.StagedTable({ stats = { health = 100 } })
local view = staged:GetTable()

local stats = view.stats
view.stats = nil          -- the whole table goes

print(stats.health)       --> nil
print(#stats)             --> 0

stats.health = 100        -- errors
ERR_STAGE_DETACHED | The table at root.stats is no longer part of this staged table.

The view is not spent. Put a table back at that key and the one you are holding reads it, so this only ever bites in the window where the key holds nothing.

Recently fixed

These three were listed here as bugs. If you wrote code around any of them, you can stop.

Passing nil works

An argument list used to stop at the first nil, so Emit("Values", 1, nil, 3) arrived as a single argument, and so did a reply of 1, nil, 3. Arguments now cross the wire with a stand-in in place of each nil and the far side puts the nil back, so leading, middle, trailing and all-nil lists arrive intact in every direction, replies included. A nil inside a table you pass is still a hole in that table, which is how Roblox has always handled it, so use explicit keys if you need one.

Client
echo:Emit("Values", 1, nil, 3)   --> the server gets 1, nil, 3

On no longer revives a destroyed carrier

On used to install a live transport listener on a carrier that was already destroyed, because the only thing it checked was the transformer, and a transformer you passed in survives carrier:Destroy(). Nothing would ever disconnect that listener. On now returns straight away on a dropped carrier, so calling it after Destroy does nothing at all.

A nested view follows its table being replaced

A view you read out of another view used to hold the inner table itself. When a patch or a snapshot replaced that whole table, the view you were still holding kept showing the old contents, and the advice was to read down from the root every time and never keep the middle of a path in a variable.

A view now holds the way to find its table again rather than the table, and asks its parent for the value at its key on every read, write, length check and loop. So local stats = view.stats keeps working after the server replaces the whole stats table, including keys that were not there before, at any depth, and through a fresh snapshot as much as a patch.

Client
local view = data:RequestTable("me"):GetTable()

local stats = view.stats
print(stats.health)   --> 100

-- The server replaces the whole stats table with a new one.

print(stats.health)   --> the new value

What to read next