Harden multiplayer session terminal handling

This commit is contained in:
Andrew Barnes
2026-08-06 15:29:37 -04:00
parent bd6c606303
commit 2a7c8ec81b
3 changed files with 330 additions and 18 deletions
+5 -6
View File
@@ -207,7 +207,7 @@ function Net:send(msg)
end
if self.peerEnd then -- loopback: re-encode through json like the wire
local decoded = Json.decode(Json.encode(msg))
if decoded and not self.peerEnd.closed then
if decoded ~= nil and not self.peerEnd.closed then
table.insert(self.peerEnd.inbox, decoded)
end
return
@@ -256,13 +256,12 @@ end
function Net:handleTCPLine(line)
local msg = Json.decode(line)
if not msg then
if msg == nil then
Logger.warn("link: bad relay message %q", line:sub(1, 60))
return
end
if not handleGenericRelayControl(self, msg) then
table.insert(self.inbox, msg)
end
if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end
table.insert(self.inbox, msg)
end
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
@@ -352,7 +351,7 @@ function Net:update()
end
elseif event.type == "receive" then
local msg = Json.decode(event.data)
if msg then
if msg ~= nil then
table.insert(self.inbox, msg)
else
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
+107 -12
View File
@@ -48,13 +48,43 @@ function Session:_refreshStatus()
self._status = self.paired and "paired" or "connecting"
self.closed = false
self.error = nil
return
end
if #self._inbox > 0 then
self._status = "draining"
self.closed = false
self.error = nil
return
end
self._status = self._terminal.status
self.closed = true
self.error = self._terminal.status == "failed"
and (self._terminal.detail or self._terminal.reason) or nil
end
function Session:_latchTerminal(status, reason, detail)
if self._terminal then return false end
self._terminal = { status = status, reason = reason, detail = detail }
self:_refreshStatus()
return true
end
function Session:_closeTransport()
if self._transportCloseCalled then return true end
self._transportCloseCalled = true
local ok, detail = pcall(self._transport.close, self._transport)
return ok, ok and nil or tostring(detail)
end
function Session:getRole() return self._role end
function Session:getKind() return self._kind end
function Session:getStatus() return self._status end
function Session:getFailure() return nil, nil end
function Session:getFailure()
if not self._terminal or self._terminal.status ~= "failed" then
return nil, nil
end
return self._terminal.reason, self._terminal.detail
end
function Session:hasPending() return #self._inbox > 0 end
function Session:send(message)
@@ -63,13 +93,62 @@ function Session:send(message)
end
function Session:update()
if self._terminal then return end
self._transport:update()
self:_syncMetadata()
local messages = self._transport:poll()
for _, message in ipairs(messages) do
self._inbox[#self._inbox + 1] = message
if self._terminal then
self:_refreshStatus()
return
end
local failureReason, failureDetail
local updateOk, updateDetail = pcall(self._transport.update, self._transport)
self:_syncMetadata()
if not updateOk then
failureReason, failureDetail = "transport_error", tostring(updateDetail)
elseif self._transport.error then
failureReason = "transport_error"
failureDetail = tostring(self._transport.error)
end
local pollOk, messages = pcall(self._transport.poll, self._transport)
if not pollOk then
if not failureReason then
failureReason, failureDetail = "transport_error", tostring(messages)
end
elseif type(messages) ~= "table" then
if not failureReason then
failureReason, failureDetail = "transport_error",
"transport poll returned non-table"
end
else
for index = 1, #messages do
local message = messages[index]
if type(message) ~= "table" or type(message.type) ~= "string" then
if not failureReason then
failureReason = "protocol_error"
failureDetail = ("message %d must be a table with string type")
:format(index)
end
break
end
self._inbox[#self._inbox + 1] = message
end
end
self:_syncMetadata()
if failureReason then
self:_latchTerminal("failed", failureReason, failureDetail)
self:_closeTransport()
elseif self._transport.closed then
local closeOk, closeDetail = self:_closeTransport()
if closeOk then
self:_latchTerminal("closed")
else
self:_latchTerminal("failed", "transport_error", closeDetail)
end
end
self:_refreshStatus()
end
local function finishRead(self)
self:_refreshStatus()
end
@@ -77,7 +156,9 @@ function Session:take(messageType)
assert(type(messageType) == "string", "Session.take requires a message type")
for index, message in ipairs(self._inbox) do
if message.type == messageType then
return table.remove(self._inbox, index)
local found = table.remove(self._inbox, index)
finishRead(self)
return found
end
end
return nil
@@ -85,19 +166,33 @@ end
function Session:pollOne()
if #self._inbox == 0 then return nil end
return table.remove(self._inbox, 1)
local message = table.remove(self._inbox, 1)
finishRead(self)
return message
end
function Session:poll()
local messages = self._inbox
self._inbox = {}
finishRead(self)
return messages
end
function Session:close()
if self._transportCloseCalled then return end
self._transportCloseCalled = true
self._transport:close()
if self._status == "closed" or self._status == "failed" then return end
if self._terminal then
self:_closeTransport()
self:_refreshStatus()
return
end
local ok, detail = self:_closeTransport()
if ok then
self:_latchTerminal("closed")
else
self:_latchTerminal("failed", "transport_error", detail)
end
self:_syncMetadata()
self:_refreshStatus()
end
return Session