The big topic chunking commit

- Implemented topic request and response chunking
- Removed GitHub workflows from .sln as they are auto-detected
- Interop version bump to 5.6.0
- DMAPI version bump to 6.4.0
- Split common chunking code into its own DMAPI/v5 file
- Added DMAPI/v5 file just for all the new topic handling methods
- Fixed runtime errors duing Topic calls not being reported to TGS
- Fixed certain topic requests returning lists instead of JSON
- Bridge and topic response chunk handling merged into `BridgeRequestChunker` now just `Chunker`
- `CombinedTopicResponse` is no longer used outside of SessionController
This commit is contained in:
Dominion
2023-04-16 17:31:48 -04:00
parent 7cee137e43
commit 70689d8036
29 changed files with 899 additions and 412 deletions
+2 -2
View File
@@ -8,8 +8,8 @@
<TgsApiVersion>9.9.0</TgsApiVersion>
<TgsApiLibraryVersion>10.3.0</TgsApiLibraryVersion>
<TgsClientVersion>11.3.0</TgsClientVersion>
<TgsDmapiVersion>6.3.1</TgsDmapiVersion>
<TgsInteropVersion>5.5.0</TgsInteropVersion>
<TgsDmapiVersion>6.4.0</TgsDmapiVersion>
<TgsInteropVersion>5.6.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.2.1</TgsHostWatchdogVersion>
<TgsContainerScriptVersion>1.2.1</TgsContainerScriptVersion>
<TgsMigratorVersion>1.0.1</TgsMigratorVersion>
+1 -1
View File
@@ -1,6 +1,6 @@
// tgstation-server DMAPI
#define TGS_DMAPI_VERSION "6.3.1"
#define TGS_DMAPI_VERSION "6.4.0"
// All functions and datums outside this document are subject to change with any version and should not be relied on.
+2
View File
@@ -14,6 +14,8 @@
#include "v5\_defines.dm"
#include "v5\api.dm"
#include "v5\bridge.dm"
#include "v5\chunking.dm"
#include "v5\commands.dm"
#include "v5\serializers.dm"
#include "v5\topic.dm"
#include "v5\undefs.dm"
+3 -1
View File
@@ -5,7 +5,9 @@ This DMAPI implements bridge requests using HTTP GET requests to TGS. It has no
- [__interop_version.dm](./__interop_version.dm) contains the version of the API used between the DMAPI and TGS.
- [_defines.dm](./_defines.dm) contains constant definitions.
- [api.dm](./api.dm) contains the bulk of the API code.
- [bridge.dm](./bridge.dm) contains the functions related to making bridge requests.
- [bridge.dm](./bridge.dm) contains functions related to making bridge requests.
- [chunking.dm](./chunking.dm) contains common function for splitting large raw data sets into chunks BYOND can natively process.
- [commands.dm](./commands.dm) contains functions relating to `/datum/tgs_chat_command`s.
- [serializers.dm](./serializers.dm) contains function to help convert interop `/datum`s into a JSON encodable `list()` format.
- [topic.dm](./topic.dm) contains functions related to processing topic requests.
- [undefs.dm](./undefs.dm) Undoes the work of `_defines.dm`.
+1 -1
View File
@@ -1 +1 @@
"5.5.0"
"5.6.0"
+12 -1
View File
@@ -5,6 +5,8 @@
#define DMAPI5_TOPIC_DATA "tgs_data"
#define DMAPI5_BRIDGE_REQUEST_LIMIT 8198
#define DMAPI5_TOPIC_REQUEST_LIMIT 65529
#define DMAPI5_TOPIC_RESPONSE_LIMIT 65528
#define DMAPI5_BRIDGE_COMMAND_PORT_UPDATE 0
#define DMAPI5_BRIDGE_COMMAND_STARTUP 1
@@ -17,6 +19,14 @@
#define DMAPI5_PARAMETER_ACCESS_IDENTIFIER "accessIdentifier"
#define DMAPI5_PARAMETER_CUSTOM_COMMANDS "customCommands"
#define DMAPI5_CHUNK "chunk"
#define DMAPI5_CHUNK_PAYLOAD "payload"
#define DMAPI5_CHUNK_TOTAL "totalChunks"
#define DMAPI5_CHUNK_SEQUENCE_ID "sequenceId"
#define DMAPI5_CHUNK_PAYLOAD_ID "payloadId"
#define DMAPI5_MISSING_CHUNKS "missingChunks"
#define DMAPI5_RESPONSE_ERROR_MESSAGE "errorMessage"
#define DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE "commandType"
@@ -27,7 +37,6 @@
#define DMAPI5_BRIDGE_RESPONSE_NEW_PORT "newPort"
#define DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION "runtimeInformation"
#define DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS "missingChunks"
#define DMAPI5_CHAT_MESSAGE_CHANNEL_IDS "channelIds"
@@ -68,6 +77,8 @@
#define DMAPI5_TOPIC_COMMAND_SERVER_PORT_UPDATE 6
#define DMAPI5_TOPIC_COMMAND_HEARTBEAT 7
#define DMAPI5_TOPIC_COMMAND_WATCHDOG_REATTACH 8
#define DMAPI5_TOPIC_COMMAND_SEND_CHUNK 9
#define DMAPI5_TOPIC_COMMAND_RECEIVE_CHUNK 10
#define DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE "commandType"
#define DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND "chatCommand"
+4 -136
View File
@@ -16,7 +16,9 @@
var/list/chat_channels
var/initialized = FALSE
var/chunked_requests = 0
var/list/chunked_topics = list()
/datum/tgs_api/v5/ApiVersion()
return new /datum/tgs_version(
@@ -98,12 +100,6 @@
/datum/tgs_api/v5/OnInitializationComplete()
Bridge(DMAPI5_BRIDGE_COMMAND_PRIME)
/datum/tgs_api/v5/proc/TopicResponse(error_message = null)
var/list/response = list()
if(error_message)
response[DMAPI5_RESPONSE_ERROR_MESSAGE] = error_message
return response
/datum/tgs_api/v5/OnTopic(T)
RequireInitialBridgeResponse()
var/list/params = params2list(T)
@@ -111,139 +107,11 @@
if(!json)
return FALSE // continue to /world/Topic
var/list/topic_parameters = json_decode(json)
if(!topic_parameters)
return TopicResponse("Invalid topic parameters json!");
if(!initialized)
TGS_WARNING_LOG("Missed topic due to not being initialized: [T]")
TGS_WARNING_LOG("Missed topic due to not being initialized: [json]")
return TRUE // too early to handle, but it's still our responsibility
var/their_sCK = topic_parameters[DMAPI5_PARAMETER_ACCESS_IDENTIFIER]
if(their_sCK != access_identifier)
return TopicResponse("Failed to decode [DMAPI5_PARAMETER_ACCESS_IDENTIFIER] from: [json]!");
var/command = topic_parameters[DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE]
if(!isnum(command))
return TopicResponse("Failed to decode [DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE] from: [json]!")
var/result = ProcessTopicCommand(command, topic_parameters)
if(!length(result))
return "{}" // quirk of json_encode is an empty list returns "[]"
return json_encode(result)
/datum/tgs_api/v5/proc/ProcessTopicCommand(command, list/topic_parameters)
switch(command)
if(DMAPI5_TOPIC_COMMAND_CHAT_COMMAND)
intercepted_message_queue = list()
var/list/result = HandleCustomCommand(topic_parameters[DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND])
if(!result)
result = TopicResponse("Error running chat command!")
result[DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES] = intercepted_message_queue
intercepted_message_queue = null
return result
if(DMAPI5_TOPIC_COMMAND_EVENT_NOTIFICATION)
intercepted_message_queue = list()
var/list/event_notification = topic_parameters[DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION]
if(!istype(event_notification))
return TopicResponse("Invalid [DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION]!")
var/event_type = event_notification[DMAPI5_EVENT_NOTIFICATION_TYPE]
if(!isnum(event_type))
return TopicResponse("Invalid or missing [DMAPI5_EVENT_NOTIFICATION_TYPE]!")
var/list/event_parameters = event_notification[DMAPI5_EVENT_NOTIFICATION_PARAMETERS]
if(event_parameters && !istype(event_parameters))
return TopicResponse("Invalid or missing [DMAPI5_EVENT_NOTIFICATION_PARAMETERS]!")
var/list/event_call = list(event_type)
if(event_parameters)
event_call += event_parameters
if(event_handler != null)
event_handler.HandleEvent(arglist(event_call))
var/list/response = TopicResponse()
response[DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES] = intercepted_message_queue
intercepted_message_queue = null
return response
if(DMAPI5_TOPIC_COMMAND_CHANGE_PORT)
var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
if (!isnum(new_port) || !(new_port > 0))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]")
if(event_handler != null)
event_handler.HandleEvent(TGS_EVENT_PORT_SWAP, new_port)
//the topic still completes, miraculously
//I honestly didn't believe byond could do it without exploding
if(!world.OpenPort(new_port))
return TopicResponse("Port change failed!")
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_CHANGE_REBOOT_STATE)
var/new_reboot_mode = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE]
if(!isnum(new_reboot_mode))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE]!")
if(event_handler != null)
event_handler.HandleEvent(TGS_EVENT_REBOOT_MODE_CHANGE, reboot_mode, new_reboot_mode)
reboot_mode = new_reboot_mode
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_INSTANCE_RENAMED)
var/new_instance_name = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME]
if(!istext(new_instance_name))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME]!")
if(event_handler != null)
event_handler.HandleEvent(TGS_EVENT_INSTANCE_RENAMED, new_instance_name)
instance_name = new_instance_name
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_CHAT_CHANNELS_UPDATE)
var/list/chat_update_json = topic_parameters[DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE]
if(!istype(chat_update_json))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE]!")
DecodeChannels(chat_update_json)
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_SERVER_PORT_UPDATE)
var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
if (!isnum(new_port) || !(new_port > 0))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]")
server_port = new_port
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_HEARTBEAT)
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_WATCHDOG_REATTACH)
var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
var/error_message = null
if (new_port != null)
if (!isnum(new_port) || !(new_port > 0))
error_message = "Invalid [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]"
else
server_port = new_port
var/new_version_string = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION]
if (!istext(new_version_string))
if(error_message != null)
error_message += ", "
error_message += "Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION]]"
else
var/datum/tgs_version/new_version = new(new_version_string)
if (event_handler)
event_handler.HandleEvent(TGS_EVENT_WATCHDOG_REATTACH, new_version)
version = new_version
var/list/reattach_response = TopicResponse(error_message)
reattach_response[DMAPI5_PARAMETER_CUSTOM_COMMANDS] = ListCustomCommands()
return reattach_response
return TopicResponse("Unknown command: [command]")
return ProcessTopicJson(json, TRUE)
/datum/tgs_api/v5/OnReboot()
var/list/result = Bridge(DMAPI5_BRIDGE_COMMAND_REBOOT)
+6 -27
View File
@@ -10,29 +10,8 @@
var/payload_id = ++chunked_requests
var/raw_data = CreateBridgeData(command, data, FALSE)
var/data_length = length(raw_data)
var/chunk_count
var/list/chunk_requests
for(chunk_count = 2; !chunk_requests; ++chunk_count);
var/max_chunk_size = -round(-(data_length / chunk_count))
if(max_chunk_size > DMAPI5_BRIDGE_REQUEST_LIMIT)
continue
chunk_requests = list()
for(var/i in 1 to chunk_count)
var/startIndex = 1 + ((i - 1) * max_chunk_size)
var/endIndex = min(1 + (i * max_chunk_size), data_length + 1)
var/chunk_payload = copytext(raw_data, startIndex, endIndex)
var/list/chunk = list("payloadId" = payload_id, "sequenceId" = (i - 1), "totalChunks" = chunk_count, payload = chunk_payload)
var/chunk_request = CreateBridgeRequest(DMAPI5_BRIDGE_COMMAND_CHUNK, list("chunk" = chunk))
if(length(chunk_request) > DMAPI5_BRIDGE_REQUEST_LIMIT)
// Screwed by url encoding, no way to preempt it though
chunk_requests = null
break
chunk_requests += chunk_request
var/list/chunk_requests = GenerateChunks(raw_data, TRUE)
var/list/response
for(var/bridge_request in chunk_requests)
@@ -41,17 +20,17 @@
// Abort
return
var/list/missing_sequence_ids = response[DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]
var/list/missing_sequence_ids = response[DMAPI5_MISSING_CHUNKS]
if(length(missing_sequence_ids))
do
TGS_WARNING_LOG("Server is missing some chunks of payload [payload_id]! Sending missing chunks...")
TGS_WARNING_LOG("Server is still missing some chunks of bridge P[payload_id]! Sending missing chunks...")
if(!istype(missing_sequence_ids))
TGS_ERROR_LOG("Did not receive a list() for [DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]!")
TGS_ERROR_LOG("Did not receive a list() for [DMAPI5_MISSING_CHUNKS]!")
return
for(var/missing_sequence_id in missing_sequence_ids)
if(!isnum(missing_sequence_id))
TGS_ERROR_LOG("Did not receive a num in [DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]!")
TGS_ERROR_LOG("Did not receive a num in [DMAPI5_MISSING_CHUNKS]!")
return
var/missing_chunk_request = chunk_requests[missing_sequence_id + 1]
@@ -60,7 +39,7 @@
// Abort
return
missing_sequence_ids = response[DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]
missing_sequence_ids = response[DMAPI5_MISSING_CHUNKS]
while(length(missing_sequence_ids))
return response
+43
View File
@@ -0,0 +1,43 @@
/datum/tgs_api/v5/proc/GenerateChunks(payload, bridge)
var/limit = bridge ? DMAPI5_BRIDGE_REQUEST_LIMIT : DMAPI5_TOPIC_RESPONSE_LIMIT
var/payload_id = ++chunked_requests
var/data_length = length(payload)
var/chunk_count
var/list/chunk_requests
for(chunk_count = 2; !chunk_requests; ++chunk_count);
var/max_chunk_size = -round(-(data_length / chunk_count))
if(max_chunk_size > limit)
continue
chunk_requests = list()
for(var/i in 1 to chunk_count)
var/start_index = 1 + ((i - 1) * max_chunk_size)
if (start_index > data_length)
break
var/end_index = min(1 + (i * max_chunk_size), data_length + 1)
var/chunk_payload = copytext(payload, start_index, end_index)
// sequence IDs in interop chunking are always zero indexed
var/list/chunk = list(DMAPI5_CHUNK_PAYLOAD_ID = payload_id, DMAPI5_CHUNK_SEQUENCE_ID = (i - 1), DMAPI5_CHUNK_TOTAL = chunk_count, DMAPI5_CHUNK_PAYLOAD = chunk_payload)
var/chunk_request = list(DMAPI5_CHUNK = chunk)
var/chunk_length
if(bridge)
chunk_request = CreateBridgeRequest(DMAPI5_BRIDGE_COMMAND_CHUNK, chunk_request)
chunk_length = length(chunk_request)
else
chunk_request = list(chunk_request) // wrap for adding to list
chunk_length = length(json_encode(chunk_request))
if(chunk_length > limit)
// Screwed by encoding, no way to preempt it though
chunk_requests = null
break
chunk_requests += chunk_request
return chunk_requests
+254
View File
@@ -0,0 +1,254 @@
/datum/tgs_api/v5/proc/TopicResponse(error_message = null)
var/list/response = list()
if(error_message)
response[DMAPI5_RESPONSE_ERROR_MESSAGE] = error_message
return response
/datum/tgs_api/v5/proc/ProcessTopicJson(json, check_access_identifier)
var/list/result = ProcessRawTopic(json, check_access_identifier)
if(!result)
result = TopicResponse("Runtime error!")
else if(!length(result))
return "{}" // quirk of json_encode is an empty list returns "[]"
var/response_json = json_encode(result)
if(length(response_json) > DMAPI5_TOPIC_RESPONSE_LIMIT)
// cache response chunks and send the first
var/list/chunks = GenerateChunks(response_json, FALSE)
var/payload_id = chunks[1][DMAPI5_CHUNK][DMAPI5_CHUNK_PAYLOAD_ID]
var/cache_key = ResponseTopicChunkCacheKey(payload_id)
chunked_topics[cache_key] = chunks
response_json = json_encode(chunks[1])
return response_json
/datum/tgs_api/v5/proc/ProcessRawTopic(json, check_access_identifier)
var/list/topic_parameters = json_decode(json)
if(!topic_parameters)
return TopicResponse("Invalid topic parameters json: [json]!");
var/their_sCK = topic_parameters[DMAPI5_PARAMETER_ACCESS_IDENTIFIER]
if(check_access_identifier && their_sCK != access_identifier)
return TopicResponse("Failed to decode [DMAPI5_PARAMETER_ACCESS_IDENTIFIER]!")
var/command = topic_parameters[DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE]
if(!isnum(command))
return TopicResponse("Failed to decode [DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE]!")
return ProcessTopicCommand(command, topic_parameters)
/datum/tgs_api/v5/proc/ResponseTopicChunkCacheKey(payload_id)
return "response[payload_id]"
/datum/tgs_api/v5/proc/ProcessTopicCommand(command, list/topic_parameters)
switch(command)
if(DMAPI5_TOPIC_COMMAND_CHAT_COMMAND)
intercepted_message_queue = list()
var/list/result = HandleCustomCommand(topic_parameters[DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND])
if(!result)
result = TopicResponse("Error running chat command!")
result[DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES] = intercepted_message_queue
intercepted_message_queue = null
return result
if(DMAPI5_TOPIC_COMMAND_EVENT_NOTIFICATION)
intercepted_message_queue = list()
var/list/event_notification = topic_parameters[DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION]
if(!istype(event_notification))
return TopicResponse("Invalid [DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION]!")
var/event_type = event_notification[DMAPI5_EVENT_NOTIFICATION_TYPE]
if(!isnum(event_type))
return TopicResponse("Invalid or missing [DMAPI5_EVENT_NOTIFICATION_TYPE]!")
var/list/event_parameters = event_notification[DMAPI5_EVENT_NOTIFICATION_PARAMETERS]
if(event_parameters && !istype(event_parameters))
return TopicResponse("Invalid or missing [DMAPI5_EVENT_NOTIFICATION_PARAMETERS]!")
var/list/event_call = list(event_type)
if(event_parameters)
event_call += event_parameters
if(event_handler != null)
event_handler.HandleEvent(arglist(event_call))
var/list/response = TopicResponse()
response[DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES] = intercepted_message_queue
intercepted_message_queue = null
return response
if(DMAPI5_TOPIC_COMMAND_CHANGE_PORT)
var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
if (!isnum(new_port) || !(new_port > 0))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]")
if(event_handler != null)
event_handler.HandleEvent(TGS_EVENT_PORT_SWAP, new_port)
//the topic still completes, miraculously
//I honestly didn't believe byond could do it without exploding
if(!world.OpenPort(new_port))
return TopicResponse("Port change failed!")
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_CHANGE_REBOOT_STATE)
var/new_reboot_mode = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE]
if(!isnum(new_reboot_mode))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE]!")
if(event_handler != null)
event_handler.HandleEvent(TGS_EVENT_REBOOT_MODE_CHANGE, reboot_mode, new_reboot_mode)
reboot_mode = new_reboot_mode
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_INSTANCE_RENAMED)
var/new_instance_name = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME]
if(!istext(new_instance_name))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME]!")
if(event_handler != null)
event_handler.HandleEvent(TGS_EVENT_INSTANCE_RENAMED, new_instance_name)
instance_name = new_instance_name
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_CHAT_CHANNELS_UPDATE)
var/list/chat_update_json = topic_parameters[DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE]
if(!istype(chat_update_json))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE]!")
DecodeChannels(chat_update_json)
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_SERVER_PORT_UPDATE)
var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
if (!isnum(new_port) || !(new_port > 0))
return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]")
server_port = new_port
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_HEARTBEAT)
return TopicResponse()
if(DMAPI5_TOPIC_COMMAND_WATCHDOG_REATTACH)
var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
var/error_message = null
if (new_port != null)
if (!isnum(new_port) || !(new_port > 0))
error_message = "Invalid [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]"
else
server_port = new_port
var/new_version_string = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION]
if (!istext(new_version_string))
if(error_message != null)
error_message += ", "
error_message += "Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION]]"
else
var/datum/tgs_version/new_version = new(new_version_string)
if (event_handler)
event_handler.HandleEvent(TGS_EVENT_WATCHDOG_REATTACH, new_version)
version = new_version
var/list/reattach_response = TopicResponse(error_message)
reattach_response[DMAPI5_PARAMETER_CUSTOM_COMMANDS] = ListCustomCommands()
return reattach_response
if(DMAPI5_TOPIC_COMMAND_SEND_CHUNK)
var/list/chunk = topic_parameters[DMAPI5_CHUNK]
if(!istype(chunk))
return TopicResponse("Invalid [DMAPI5_CHUNK]!")
var/payload_id = chunk[DMAPI5_CHUNK_PAYLOAD_ID]
if(!isnum(payload_id))
return TopicResponse("[DMAPI5_CHUNK_PAYLOAD_ID] is not a number!")
// Always updated the highest known payload ID
chunked_requests = max(chunked_requests, payload_id)
var/sequence_id = chunk[DMAPI5_CHUNK_SEQUENCE_ID]
if(!isnum(sequence_id))
return TopicResponse("[DMAPI5_CHUNK_SEQUENCE_ID] is not a number!")
var/total_chunks = chunk[DMAPI5_CHUNK_TOTAL]
if(!isnum(total_chunks))
return TopicResponse("[DMAPI5_CHUNK_TOTAL] is not a number!")
if(total_chunks == 0)
return TopicResponse("[DMAPI5_CHUNK_TOTAL] is zero!")
var/payload = chunk[DMAPI5_CHUNK_PAYLOAD]
if(!istext(payload))
return TopicResponse("[DMAPI5_CHUNK_PAYLOAD] is not text!")
var/cache_key = "request[payload_id]"
var/payloads = chunked_topics[cache_key]
if(!payloads)
payloads = new /list(total_chunks)
chunked_topics[cache_key] = payloads
if(total_chunks != length(payloads))
chunked_topics -= cache_key
return TopicResponse("Received differing total chunks for same [DMAPI5_CHUNK_PAYLOAD_ID]! Invalidating [DMAPI5_CHUNK_PAYLOAD_ID]!")
var/pre_existing_chunk = payloads[sequence_id + 1]
if(pre_existing_chunk && pre_existing_chunk != payload)
chunked_topics -= cache_key
return TopicResponse("Received differing payload for same [DMAPI5_CHUNK_SEQUENCE_ID]! Invalidating [DMAPI5_CHUNK_PAYLOAD_ID]!")
payloads[sequence_id + 1] = payload
var/list/missing_sequence_ids = list()
for(var/i in 1 to total_chunks)
if(!payloads[i])
missing_sequence_ids += i - 1
if(length(missing_sequence_ids))
return list(DMAPI5_MISSING_CHUNKS = missing_sequence_ids)
chunked_topics -= cache_key
var/full_json = jointext(payloads, "")
return ProcessRawTopic(full_json, FALSE)
if(DMAPI5_TOPIC_COMMAND_RECEIVE_CHUNK)
var/payload_id = topic_parameters[DMAPI5_CHUNK_PAYLOAD_ID]
if(!isnum(payload_id))
return TopicResponse("[DMAPI5_CHUNK_PAYLOAD_ID] is not a number!")
// Always updated the highest known payload ID
chunked_requests = max(chunked_requests, payload_id)
var/list/missing_chunks = topic_parameters[DMAPI5_MISSING_CHUNKS]
if(!istype(missing_chunks) || !length(missing_chunks))
return TopicResponse("Missing or empty [DMAPI5_MISSING_CHUNKS]!")
var/sequence_id_to_send = missing_chunks[1]
if(!isnum(sequence_id_to_send))
return TopicResponse("[DMAPI5_MISSING_CHUNKS] contained a non-number!")
var/cache_key = ResponseTopicChunkCacheKey(payload_id)
var/list/chunks = chunked_topics[cache_key]
if(!chunks)
return TopicResponse("Unknown response chunk set: P[payload_id]!")
// sequence IDs in interop chunking are always zero indexed
var/chunk_to_send = chunks[sequence_id_to_send + 1]
if(!chunk_to_send)
return TopicResponse("Sequence ID [sequence_id_to_send] is not present in response chunk P[payload_id]!")
if(length(missing_chunks) == 1)
// sending last chunk, purge the cache
chunked_topics -= cache_key
return chunk_to_send
return TopicResponse("Unknown command: [command]")
+12
View File
@@ -4,6 +4,10 @@
#undef DMAPI5_BRIDGE_DATA
#undef DMAPI5_TOPIC_DATA
#undef DMAPI5_BRIDGE_REQUEST_LIMIT
#undef DMAPI5_TOPIC_REQUEST_LIMIT
#undef DMAPI5_TOPIC_RESPONSE_LIMIT
#undef DMAPI5_BRIDGE_COMMAND_PORT_UPDATE
#undef DMAPI5_BRIDGE_COMMAND_STARTUP
#undef DMAPI5_BRIDGE_COMMAND_PRIME
@@ -14,6 +18,14 @@
#undef DMAPI5_PARAMETER_ACCESS_IDENTIFIER
#undef DMAPI5_PARAMETER_CUSTOM_COMMANDS
#undef DMAPI5_CHUNK
#undef DMAPI5_CHUNK_PAYLOAD
#undef DMAPI5_CHUNK_TOTAL
#undef DMAPI5_CHUNK_SEQUENCE_ID
#undef DMAPI5_CHUNK_PAYLOAD_ID
#undef DMAPI5_MISSING_CHUNKS
#undef DMAPI5_RESPONSE_ERROR_MESSAGE
#undef DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE
@@ -1,131 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// Processes chunked bridge requests.
/// </summary>
abstract class BridgeRequestChunker : IBridgeDispatcher
{
/// <summary>
/// The cache of chunked bridge requests.
/// </summary>
/// <remarks>If the DMAPI is erroring, this can present a memory leak. Worth expiring entries if they aren't completed after some minutes.</remarks>
readonly Dictionary<uint, Tuple<ChunkedRequestInfo, string[]>> bridgeChunks;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="BridgeRequestChunker"/>.
/// </summary>
protected ILogger<BridgeRequestChunker> Logger { get; }
/// <summary>
/// Initializes a new instance of the <see cref="BridgeRequestChunker"/> class.
/// </summary>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected BridgeRequestChunker(ILogger<BridgeRequestChunker> logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
bridgeChunks = new Dictionary<uint, Tuple<ChunkedRequestInfo, string[]>>();
}
/// <inheritdoc />
public abstract Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken);
/// <summary>
/// Process a given <paramref name="chunk"/>.
/// </summary>
/// <param name="chunk">The <see cref="ChunkData"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="BridgeResponse"/> for the chunked request.</returns>
protected async Task<BridgeResponse> ProcessBridgeChunk(ChunkData chunk, CancellationToken cancellationToken)
{
if (chunk == null)
return BridgeError("Missing chunk!");
ChunkedRequestInfo requestInfo;
string[] payloads;
lock (bridgeChunks)
{
if (!bridgeChunks.TryGetValue(chunk.PayloadId, out var tuple))
{
// first time seeing this payload
if (chunk.TotalChunks == 0)
return BridgeError("Receieved chunked request with 0 totalChunks!");
tuple = Tuple.Create<ChunkedRequestInfo, string[]>(chunk, new string[chunk.TotalChunks]);
bridgeChunks.Add(chunk.PayloadId, tuple);
}
requestInfo = tuple.Item1;
payloads = tuple.Item2;
Logger.LogTrace("Received bridge payload chunk P{payloadId}: {sequenceId}/{totalChunks}", requestInfo.PayloadId, chunk.SequenceId + 1, requestInfo.TotalChunks);
if (chunk.TotalChunks != requestInfo.TotalChunks)
{
bridgeChunks.Remove(requestInfo.PayloadId);
return BridgeError("Received differing total chunks for same payloadId! Invalidating payloadId!");
}
if (payloads[chunk.SequenceId] != null && payloads[chunk.SequenceId] != chunk.Payload)
{
bridgeChunks.Remove(requestInfo.PayloadId);
return BridgeError("Received differing payload for same sequenceId! Invalidating payloadId!");
}
payloads[chunk.SequenceId] = chunk.Payload;
var missingPayloads = new List<uint>();
for (uint i = 0; i < payloads.Length; ++i)
if (payloads[i] == null)
missingPayloads.Add(i);
if (missingPayloads.Count > 0)
return new BridgeResponse
{
MissingChunks = missingPayloads,
};
Logger.LogTrace("Received all bridge chunks for P{payloadId}, processing request...", requestInfo.PayloadId);
bridgeChunks.Remove(requestInfo.PayloadId);
}
BridgeParameters completedRequest;
var fullRequestJson = String.Concat(payloads);
try
{
completedRequest = JsonConvert.DeserializeObject<BridgeParameters>(fullRequestJson, DMApiConstants.SerializerSettings);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Bad chunked bridge request for payload {payloadId}!", requestInfo.PayloadId);
return BridgeError("Chunked request completed with bad JSON!", false);
}
return await ProcessBridgeRequest(completedRequest, cancellationToken);
}
/// <summary>
/// Create and logs an errored <see cref="BridgeResponse"/>.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="log">If <paramref name="message"/> should be written to the <see cref="Logger"/>.</param>
/// <returns>A new errored <see cref="BridgeResponse"/>.</returns>
protected BridgeResponse BridgeError(string message, bool log = true)
{
if (log)
Logger.LogWarning("Bridge processing error: {errorMessage}", message);
return new BridgeResponse
{
ErrorMessage = message,
};
}
}
}
@@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Components.Interop.Bridge
/// <summary>
/// A response to a bridge request.
/// </summary>
public class BridgeResponse : DMApiResponse
public class BridgeResponse : DMApiResponse, IMissingPayloadsCommunication
{
/// <summary>
/// The new port for <see cref="BridgeCommandType.Reboot"/> requests.
@@ -1,18 +0,0 @@
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// A packet of a split serialized set of <see cref="BridgeParameters"/>.
/// </summary>
public sealed class ChunkData : ChunkedRequestInfo
{
/// <summary>
/// The sequence ID of the chunk.
/// </summary>
public uint SequenceId { get; set; }
/// <summary>
/// The partial JSON payload of the chunk.
/// </summary>
public string Payload { get; set; }
}
}
@@ -1,18 +0,0 @@
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// Information about a chunked bridge request.
/// </summary>
public abstract class ChunkedRequestInfo
{
/// <summary>
/// The ID of the full request to differentiate different chunkings.
/// </summary>
public uint PayloadId { get; set; }
/// <summary>
/// The total number of chunks in the request.
/// </summary>
public uint TotalChunks { get; set; }
}
}
@@ -0,0 +1,19 @@
namespace Tgstation.Server.Host.Components.Interop
{
/// <summary>
/// A packet of a split serialized set of data.
/// </summary>
public sealed class ChunkData : ChunkSetInfo
{
/// <summary>
/// The sequence ID of the chunk.
/// </summary>
/// <remarks>Always zero indexed. Nullable to prevent default value omission.</remarks>
public uint? SequenceId { get; set; }
/// <summary>
/// The partial JSON payload of the chunk.
/// </summary>
public string Payload { get; set; }
}
}
@@ -0,0 +1,16 @@
namespace Tgstation.Server.Host.Components.Interop
{
/// <summary>
/// Information about a chunked bridge request.
/// </summary>
public abstract class ChunkSetInfo : IChunkPayloadId
{
/// <inheritdoc />
public uint? PayloadId { get; set; }
/// <summary>
/// The total number of chunks in the request.
/// </summary>
public uint TotalChunks { get; set; }
}
}
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Tgstation.Server.Host.Components.Interop
{
/// <summary>
/// Class that deserializes chunked interop payloads.
/// </summary>
abstract class Chunker
{
/// <summary>
/// Gets a payload ID for use in a new <see cref="ChunkSetInfo"/>.
/// </summary>
protected uint NextPayloadId
{
get
{
// 0 is special, since BYOND doesn't use it we reserve it for if we have to send a chunked topic request immediately upon reattaching
// Otherwise, jump ahead a bunch compared to what was last used/seen, so we don't accidentally clash
lock (chunkSets)
return highestSeenPayloadId == 0 ? 0 : highestSeenPayloadId + 20;
}
}
/// <summary>
/// The cache of chunked communications.
/// </summary>
/// <remarks>If the DMAPI is erroring, this can present a memory leak. Worth expiring entries if they aren't completed after some minutes.</remarks>
readonly Dictionary<uint, Tuple<ChunkSetInfo, string[]>> chunkSets;
/// <summary>
/// The highest payload ID value seen.
/// </summary>
uint highestSeenPayloadId;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="Chunker"/>.
/// </summary>
protected ILogger<Chunker> Logger { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Chunker"/> class.
/// </summary>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected Chunker(ILogger<Chunker> logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
chunkSets = new Dictionary<uint, Tuple<ChunkSetInfo, string[]>>();
}
/// <summary>
/// Process a given <paramref name="chunk"/>.
/// </summary>
/// <typeparam name="TCommnication">The <see cref="Type"/> of communication that was chunked.</typeparam>
/// <typeparam name="TResponse">The <see cref="Type"/> of <see cref="IMissingPayloadsCommunication"/> expected.</typeparam>
/// <param name="completionCallback">The callback that receives the completed <typeparamref name="TCommnication"/>.</param>
/// <param name="chunkErrorCallback">The callback that generates a <typeparamref name="TResponse"/> for a given error.</param>
/// <param name="chunk">The <see cref="ChunkData"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <typeparamref name="TResponse"/> for the chunked request.</returns>
protected async Task<TResponse> ProcessChunk<TCommnication, TResponse>(
Func<TCommnication, CancellationToken, Task<TResponse>> completionCallback,
Func<string, TResponse> chunkErrorCallback,
ChunkData chunk,
CancellationToken cancellationToken)
where TResponse : IMissingPayloadsCommunication, new()
{
if (chunk == null)
return chunkErrorCallback("Missing chunk!");
if (!chunk.PayloadId.HasValue)
return chunkErrorCallback("Missing chunk payloadId!");
if (!chunk.SequenceId.HasValue)
return chunkErrorCallback("Missing chunk sequenceId!");
ChunkSetInfo requestInfo;
string[] payloads;
lock (chunkSets)
{
highestSeenPayloadId = Math.Max(chunk.PayloadId.Value, highestSeenPayloadId);
if (!chunkSets.TryGetValue(chunk.PayloadId.Value, out var tuple))
{
// first time seeing this payload
if (chunk.TotalChunks == 0)
return chunkErrorCallback("Receieved chunked request with 0 totalChunks!");
tuple = Tuple.Create<ChunkSetInfo, string[]>(chunk, new string[chunk.TotalChunks]);
chunkSets.Add(chunk.PayloadId.Value, tuple);
}
requestInfo = tuple.Item1;
payloads = tuple.Item2;
Logger.LogTrace("Received chunk P{payloadId}: {sequenceId}/{totalChunks}", requestInfo.PayloadId, chunk.SequenceId + 1, requestInfo.TotalChunks);
if (chunk.TotalChunks != requestInfo.TotalChunks)
{
chunkSets.Remove(requestInfo.PayloadId.Value);
return chunkErrorCallback("Received differing total chunks for same payloadId! Invalidating payloadId!");
}
if (payloads[chunk.SequenceId.Value] != null && payloads[chunk.SequenceId.Value] != chunk.Payload)
{
chunkSets.Remove(requestInfo.PayloadId.Value);
return chunkErrorCallback("Received differing payload for same sequenceId! Invalidating payloadId!");
}
payloads[chunk.SequenceId.Value] = chunk.Payload;
var missingPayloads = new List<uint>();
for (var i = 0U; i < payloads.Length; ++i)
if (payloads[i] == null)
missingPayloads.Add(i);
if (missingPayloads.Count > 0)
return new TResponse
{
MissingChunks = missingPayloads,
};
Logger.LogTrace("Received all chunks for P{payloadId}, processing request...", requestInfo.PayloadId);
chunkSets.Remove(requestInfo.PayloadId.Value);
}
TCommnication completedCommunication;
var fullCommunicationJson = String.Concat(payloads);
try
{
completedCommunication = JsonConvert.DeserializeObject<TCommnication>(fullCommunicationJson, DMApiConstants.SerializerSettings);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Bad chunked communication for payload {payloadId}!", requestInfo.PayloadId);
return chunkErrorCallback("Chunked request completed with bad JSON!");
}
return await completionCallback(completedCommunication, cancellationToken);
}
}
}
@@ -0,0 +1,14 @@
namespace Tgstation.Server.Host.Components.Interop
{
/// <summary>
/// Represents the payload ID of a set of chunked data.
/// </summary>
public interface IChunkPayloadId
{
/// <summary>
/// The ID of the full request to differentiate different chunkings.
/// </summary>
/// <remarks>Nullable to prevent default value omission.</remarks>
uint? PayloadId { get; set; }
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace Tgstation.Server.Host.Components.Interop
{
/// <summary>
/// A communication that can be missing chunk payloads.
/// </summary>
interface IMissingPayloadsCommunication
{
/// <summary>
/// The <see cref="ChunkData.SequenceId"/>s missing from a chunked request.
/// </summary>
IReadOnlyCollection<uint> MissingChunks { get; set; }
}
}
@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace Tgstation.Server.Host.Components.Interop.Topic
{
/// <summary>
/// <see cref="TopicParameters"/> for <see cref="TopicCommandType.ReceiveChunk"/>.
/// </summary>
sealed class ChunkedTopicParameters : TopicParameters, IMissingPayloadsCommunication, IChunkPayloadId
{
/// <inheritdoc />
public IReadOnlyCollection<uint> MissingChunks { get; set; }
/// <inheritdoc />
public uint? PayloadId { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="ChunkedTopicParameters"/> class.
/// </summary>
public ChunkedTopicParameters()
: base(TopicCommandType.ReceiveChunk)
{
}
}
}
@@ -52,5 +52,15 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
/// Notify the server of a reattach and potentially new version.
/// </summary>
ServerRestarted,
/// <summary>
/// Part of a larger topic.
/// </summary>
SendChunk,
/// <summary>
/// Receive additional data for a previous response.
/// </summary>
ReceiveChunk,
}
}
@@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
/// <summary>
/// Parameters for a topic request.
/// </summary>
sealed class TopicParameters : DMApiParameters
class TopicParameters : DMApiParameters
{
/// <summary>
/// The <see cref="TopicCommandType"/>.
@@ -50,6 +50,11 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
/// </summary>
public Version NewServerVersion { get; }
/// <summary>
/// The <see cref="ChunkData"/> for a partial request.
/// </summary>
public ChunkData Chunk { get; }
/// <summary>
/// Initializes a new instance of the <see cref="TopicParameters"/> class.
/// </summary>
@@ -122,6 +127,16 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
NewPort = serverPort;
}
/// <summary>
/// Initializes a new instance of the <see cref="TopicParameters"/> class.
/// </summary>
/// <param name="chunk">The value of <see cref="Chunk"/>.</param>
public TopicParameters(ChunkData chunk)
: this(TopicCommandType.SendChunk)
{
Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk));
}
/// <summary>
/// Initializes a new instance of the <see cref="TopicParameters"/> class.
/// </summary>
@@ -135,7 +150,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
/// Initializes a new instance of the <see cref="TopicParameters"/> class.
/// </summary>
/// <param name="commandType">The value of <see cref="CommandType"/>.</param>
TopicParameters(TopicCommandType commandType)
protected TopicParameters(TopicCommandType commandType)
{
CommandType = commandType;
}
@@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
/// <summary>
/// A response to a topic request.
/// </summary>
sealed class TopicResponse : DMApiResponse
sealed class TopicResponse : DMApiResponse, IMissingPayloadsCommunication
{
/// <summary>
/// The text to reply with as the result of a <see cref="TopicCommandType.ChatCommand"/> request, if any. Deprecated circa Interop 5.4.0.
@@ -28,5 +28,13 @@ namespace Tgstation.Server.Host.Components.Interop.Topic
/// The DMAPI <see cref="CustomCommand"/>s for <see cref="TopicCommandType.ServerRestarted"/> requests.
/// </summary>
public ICollection<CustomCommand> CustomCommands { get; set; }
/// <summary>
/// The <see cref="ChunkData"/> for a partial response.
/// </summary>
public ChunkData Chunk { get; set; }
/// <inheritdoc />
public IReadOnlyCollection<uint> MissingChunks { get; set; }
}
}
@@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Components.Session
/// <param name="parameters">The <see cref="TopicParameters"/> to send.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="TopicResponse"/> of /world/Topic().</returns>
Task<CombinedTopicResponse> SendCommand(TopicParameters parameters, CancellationToken cancellationToken);
Task<TopicResponse> SendCommand(TopicParameters parameters, CancellationToken cancellationToken);
/// <summary>
/// Causes the world to start listening on a <paramref name="newPort"/>.
@@ -3,12 +3,14 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Byond.TopicSender;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Serilog.Context;
using Tgstation.Server.Api;
@@ -24,7 +26,7 @@ using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Components.Session
{
/// <inheritdoc />
sealed class SessionController : BridgeRequestChunker, ISessionController, IBridgeHandler, IChannelSink
sealed class SessionController : Chunker, ISessionController, IBridgeHandler, IChannelSink
{
/// <inheritdoc />
public DMApiParameters DMApiParameters => ReattachInformation;
@@ -91,9 +93,9 @@ namespace Tgstation.Server.Host.Components.Session
readonly CancellationTokenSource reattachTopicCts;
/// <summary>
/// The <see cref="ITopicClient"/> for the <see cref="SessionController"/>.
/// The <see cref="global::Byond.TopicSender.ITopicClient"/> for the <see cref="SessionController"/>.
/// </summary>
readonly ITopicClient byondTopicSender;
readonly global::Byond.TopicSender.ITopicClient byondTopicSender;
/// <summary>
/// The <see cref="IBridgeRegistration"/> for the <see cref="SessionController"/>.
@@ -177,7 +179,7 @@ namespace Tgstation.Server.Host.Components.Session
/// <param name="chat">The value of <see cref="chat"/>.</param>
/// <param name="chatTrackingContext">The value of <see cref="chatTrackingContext"/>.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> for the <see cref="SessionController"/>.</param>
/// <param name="logger">The value of <see cref="BridgeRequestChunker.Logger"/>.</param>
/// <param name="logger">The value of <see cref="Chunker.Logger"/>.</param>
/// <param name="postLifetimeCallback">The <see cref="Func{TResult}"/> returning a <see cref="Task"/> to be run after the <paramref name="process"/> ends.</param>
/// <param name="startupTimeout">The optional time to wait before failing the <see cref="LaunchResult"/>.</param>
/// <param name="reattached">If this is a reattached session.</param>
@@ -187,7 +189,7 @@ namespace Tgstation.Server.Host.Components.Session
Api.Models.Instance metadata,
IProcess process,
IByondExecutableLock byondLock,
ITopicClient byondTopicSender,
global::Byond.TopicSender.ITopicClient byondTopicSender,
IChatTrackingContext chatTrackingContext,
IBridgeRegistrar bridgeRegistrar,
IChatManager chat,
@@ -283,7 +285,7 @@ namespace Tgstation.Server.Host.Components.Session
}
/// <inheritdoc />
public override async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
public async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
{
if (parameters == null)
throw new ArgumentNullException(nameof(parameters));
@@ -319,7 +321,7 @@ namespace Tgstation.Server.Host.Components.Session
}
/// <inheritdoc />
public async Task<CombinedTopicResponse> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
public async Task<TopicResponse> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
{
if (parameters == null)
throw new ArgumentNullException(nameof(parameters));
@@ -338,62 +340,73 @@ namespace Tgstation.Server.Host.Components.Session
return null;
}
parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
var json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
Logger.LogTrace("Topic request: {0}", json);
TopicResponse fullResponse = null;
try
{
var commandString = String.Format(
CultureInfo.InvariantCulture,
"?{0}={1}",
byondTopicSender.SanitizeString(DMApiConstants.TopicData),
byondTopicSender.SanitizeString(json));
var combinedResponse = await SendTopicRequest(parameters, cancellationToken);
var targetPort = ReattachInformation.Port;
void LogCombinedResponse()
{
if (combinedResponse != null)
Logger.LogTrace("Topic response: {topicString}", combinedResponse.ByondTopicResponse.StringData ?? "(NO STRING DATA)");
}
var topicResponse = await byondTopicSender.SendTopic(
new IPEndPoint(IPAddress.Loopback, targetPort),
commandString,
cancellationToken);
LogCombinedResponse();
var topicReturn = topicResponse.StringData;
if (combinedResponse?.InteropResponse?.Chunk != null)
{
Logger.LogTrace("Topic response is chunked...");
Interop.Topic.TopicResponse interopResponse = null;
if (topicReturn != null)
try
ChunkData nextChunk = combinedResponse.InteropResponse.Chunk;
do
{
interopResponse = JsonConvert.DeserializeObject<Interop.Topic.TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
if (interopResponse.ErrorMessage != null)
var nextRequest = await ProcessChunk<TopicResponse, ChunkedTopicParameters>(
(completedResponse, cancellationToken) =>
{
fullResponse = completedResponse;
return Task.FromResult<ChunkedTopicParameters>(null);
},
error =>
{
Logger.LogWarning("Topic response chunking error: {message}", error);
return null;
},
combinedResponse?.InteropResponse?.Chunk,
cancellationToken);
if (nextRequest != null)
{
Logger.LogWarning("Errored topic response for command {0}: {1}", parameters.CommandType, interopResponse.ErrorMessage);
nextRequest.PayloadId = nextChunk.PayloadId;
combinedResponse = await SendTopicRequest(nextRequest, cancellationToken);
LogCombinedResponse();
nextChunk = combinedResponse?.InteropResponse?.Chunk;
}
Logger.LogTrace("Interop response: {0}", topicReturn);
else
nextChunk = null;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Invalid interop response: {0}", topicReturn);
}
return new CombinedTopicResponse(topicResponse, interopResponse);
while (nextChunk != null);
}
else
fullResponse = combinedResponse?.InteropResponse;
}
catch (OperationCanceledException ex)
{
Logger.LogTrace(
Logger.LogDebug(
ex,
"Topic request {0}!",
"Topic request {cancellationType}!",
cancellationToken.IsCancellationRequested
? "aborted"
: "timed out");
cancellationToken.ThrowIfCancellationRequested();
}
catch (Exception e)
{
Logger.LogWarning(e, "Send command exception!");
}
return null;
if (fullResponse?.ErrorMessage != null)
Logger.LogWarning(
"Errored topic response for command {commandType}: {errorMessage}",
parameters.CommandType,
fullResponse.ErrorMessage);
return fullResponse;
}
/// <inheritdoc />
@@ -411,7 +424,7 @@ namespace Tgstation.Server.Host.Components.Session
cancellationToken)
;
if (commandResult.InteropResponse?.ErrorMessage != null)
if (commandResult?.ErrorMessage != null)
return false;
ReattachInformation.Port = port;
@@ -445,7 +458,7 @@ namespace Tgstation.Server.Host.Components.Session
cancellationToken)
;
return result?.InteropResponse != null && result.InteropResponse?.ErrorMessage == null;
return result?.ErrorMessage == null;
}
/// <inheritdoc />
@@ -540,11 +553,11 @@ namespace Tgstation.Server.Host.Components.Session
if (reattachResponse != null)
{
if (reattachResponse.InteropResponse?.CustomCommands != null)
chatTrackingContext.CustomCommands = reattachResponse.InteropResponse.CustomCommands;
else if (reattachResponse.InteropResponse != null)
if (reattachResponse?.CustomCommands != null)
chatTrackingContext.CustomCommands = reattachResponse.CustomCommands;
else if (reattachResponse != null)
Logger.LogWarning(
"DMAPI v{0} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
"DMAPI Interop v{0} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
CompileJob.DMApiVersion.Semver());
}
}
@@ -698,7 +711,7 @@ namespace Tgstation.Server.Host.Components.Session
oldRebootTcs.SetResult();
break;
case BridgeCommandType.Chunk:
return await ProcessBridgeChunk(parameters.Chunk, cancellationToken);
return await ProcessChunk<BridgeParameters, BridgeResponse>(ProcessBridgeRequest, BridgeError, parameters.Chunk, cancellationToken);
case null:
return BridgeError("Missing commandType!");
default:
@@ -707,5 +720,198 @@ namespace Tgstation.Server.Host.Components.Session
return response;
}
/// <summary>
/// Log and return a <see cref="BridgeResponse"/> for a given <paramref name="message"/>.
/// </summary>
/// <param name="message">The error message.</param>
/// <returns>A new errored <see cref="BridgeResponse"/>.</returns>
BridgeResponse BridgeError(string message)
{
Logger.LogWarning("Bridge request chunking error: {message}", message);
return new BridgeResponse
{
ErrorMessage = message,
};
}
/// <summary>
/// Send a topic request for given <paramref name="parameters"/> to DreamDaemon, chunking it if necessary.
/// </summary>
/// <param name="parameters">The <see cref="TopicParameters"/> to send.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="CombinedTopicResponse"/> of the topic request.</returns>
async Task<CombinedTopicResponse> SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
{
parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
var fullCommandString = GenerateQueryString(parameters, out var json);
Logger.LogTrace("Topic request: {0}", json);
var fullCommandByteCount = Encoding.UTF8.GetByteCount(fullCommandString);
if (fullCommandByteCount <= DMApiConstants.MaximumTopicRequestLength)
return await SendRawTopic(fullCommandString, cancellationToken);
var interopChunkingVersion = new Version(5, 6, 0);
if (ReattachInformation.Dmb.CompileJob.DMApiVersion < interopChunkingVersion)
{
Logger.LogWarning(
"Cannot send topic request as it is exceeds the single request limit of {limitBytes}B ({actualBytes}B) and requires chunking and the current compile job's interop version must be at least {chunkingVersionRequired}!",
DMApiConstants.MaximumTopicRequestLength,
fullCommandByteCount,
interopChunkingVersion);
return null;
}
var payloadId = NextPayloadId;
// AccessIdentifer is just noise in a chunked request
parameters.AccessIdentifier = null;
GenerateQueryString(parameters, out json);
// yes, this straight up ignores unicode, precalculating it is useless when we don't
// even know if the UTF8 bytes of the url encoded chunk will fit the window until we do said encoding
var fullPayloadSize = (uint)json.Length;
List<string> chunkQueryStrings = null;
for (var chunkCount = 2; chunkQueryStrings == null; ++chunkCount)
{
var standardChunkSize = fullPayloadSize / chunkCount;
var bigChunkSize = standardChunkSize + (fullPayloadSize % chunkCount);
if (bigChunkSize > DMApiConstants.MaximumTopicRequestLength)
continue;
chunkQueryStrings = new List<string>();
for (var i = 0U; i < chunkCount; ++i)
{
var startIndex = i * standardChunkSize;
var subStringLength = Math.Min(
fullPayloadSize - startIndex,
i == chunkCount - 1
? bigChunkSize
: standardChunkSize);
var chunkPayload = json.Substring((int)startIndex, (int)subStringLength);
var chunk = new ChunkData
{
Payload = chunkPayload,
PayloadId = payloadId,
SequenceId = i,
TotalChunks = (uint)chunkCount,
};
var chunkParameters = new TopicParameters(chunk)
{
AccessIdentifier = ReattachInformation.AccessIdentifier,
};
var chunkCommandString = GenerateQueryString(chunkParameters, out _);
if (Encoding.UTF8.GetByteCount(chunkCommandString) > DMApiConstants.MaximumTopicRequestLength)
{
// too long when encoded, need more chunks
chunkQueryStrings = null;
break;
}
chunkQueryStrings.Add(chunkCommandString);
}
}
Logger.LogTrace("Chunking topic request ({totalChunks} total)...", chunkQueryStrings.Count);
CombinedTopicResponse combinedResponse = null;
bool LogRequestIssue(bool possiblyFromCompletedRequest)
{
if (combinedResponse?.InteropResponse == null || combinedResponse.InteropResponse.ErrorMessage != null)
{
Logger.LogWarning(
"Topic request {chunkingStatus} failed!{potentialRequestError}",
possiblyFromCompletedRequest ? "final chunk" : "chunking",
combinedResponse?.InteropResponse?.ErrorMessage != null
? $" Request error: {combinedResponse.InteropResponse.ErrorMessage}"
: String.Empty);
return true;
}
return false;
}
foreach (var chunkCommandString in chunkQueryStrings)
{
combinedResponse = await SendRawTopic(chunkCommandString, cancellationToken);
if (LogRequestIssue(chunkCommandString == chunkQueryStrings.Last()))
return null;
}
while ((combinedResponse.InteropResponse.MissingChunks?.Count ?? 0) > 0)
{
Logger.LogWarning("DD is still missing some chunks of topic request P{payloadId}! Sending missing chunks...", payloadId);
var lastIndex = combinedResponse.InteropResponse.MissingChunks.Last();
foreach (var missingChunkIndex in combinedResponse.InteropResponse.MissingChunks)
{
var chunkCommandString = chunkQueryStrings[(int)missingChunkIndex];
combinedResponse = await SendRawTopic(chunkCommandString, cancellationToken);
if (LogRequestIssue(missingChunkIndex == lastIndex))
return null;
}
}
return combinedResponse;
}
/// <summary>
/// Generates a <see cref="global::Byond.TopicSender.ITopicClient"/> query string for a given set of <paramref name="parameters"/>.
/// </summary>
/// <param name="parameters">The <see cref="TopicParameters"/> to serialize.</param>
/// <param name="json">The intermediate JSON <see cref="string"/> prior to URL encoding.</param>
/// <returns>The <see cref="global::Byond.TopicSender.ITopicClient"/> query string for the given <paramref name="parameters"/>.</returns>
string GenerateQueryString(TopicParameters parameters, out string json)
{
json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
var commandString = String.Format(
CultureInfo.InvariantCulture,
"?{0}={1}",
byondTopicSender.SanitizeString(DMApiConstants.TopicData),
byondTopicSender.SanitizeString(json));
return commandString;
}
/// <summary>
/// Send a given <paramref name="queryString"/> to DreamDaemon's /world/Topic.
/// </summary>
/// <param name="queryString">The sanitized topic query string to send.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="CombinedTopicResponse"/> of the topic request.</returns>
async Task<CombinedTopicResponse> SendRawTopic(string queryString, CancellationToken cancellationToken)
{
var targetPort = ReattachInformation.Port;
global::Byond.TopicSender.TopicResponse byondResponse;
try
{
byondResponse = await byondTopicSender.SendTopic(
new IPEndPoint(IPAddress.Loopback, targetPort),
queryString,
cancellationToken);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "SendTopic exception!");
return null;
}
var topicReturn = byondResponse.StringData;
TopicResponse interopResponse = null;
if (topicReturn != null)
try
{
interopResponse = JsonConvert.DeserializeObject<TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Invalid interop response: {topicReturnString}", topicReturn);
}
return new CombinedTopicResponse(byondResponse, interopResponse);
}
}
}
@@ -290,7 +290,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
Text = "TGS: Bad topic exchange!",
};
if (commandResult.InteropResponse == null)
if (commandResult == null)
return new MessageContent
{
Text = "TGS: Bad topic response!",
@@ -298,8 +298,8 @@ namespace Tgstation.Server.Host.Components.Watchdog
var commandResponse = new MessageContent
{
Text = commandResult.InteropResponse.CommandResponse?.Text ?? commandResult.InteropResponse.CommandResponseMessage,
Embed = commandResult.InteropResponse.CommandResponse?.Embed,
Text = commandResult.CommandResponse?.Text ?? commandResult.CommandResponseMessage,
Embed = commandResult.CommandResponse?.Embed,
};
if (commandResponse.Text == null && commandResponse.Embed == null)
@@ -1125,11 +1125,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// Handle any <see cref="TopicResponse.ChatResponses"/> in a given topic <paramref name="result"/>.
/// </summary>
/// <param name="result">The <see cref="CombinedTopicResponse"/>.</param>
void HandleChatResponses(CombinedTopicResponse result)
/// <param name="result">The <see cref="TopicResponse"/>.</param>
void HandleChatResponses(TopicResponse result)
{
if (result?.InteropResponse?.ChatResponses != null)
foreach (var response in result.InteropResponse.ChatResponses)
if (result?.ChatResponses != null)
foreach (var response in result.ChatResponses)
Chat.QueueMessage(
response,
response.ChannelIds
@@ -6,8 +6,6 @@ using System.Web;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Newtonsoft.Json;
using Tgstation.Server.Host.Components.Interop;
@@ -15,7 +13,7 @@ using Tgstation.Server.Host.Components.Interop.Bridge;
namespace Tgstation.Server.Tests.Instance
{
sealed class TestBridgeHandler : BridgeRequestChunker, IBridgeHandler
sealed class TestBridgeHandler : Chunker, IBridgeHandler
{
class DMApiParametersImpl : DMApiParameters { }
@@ -34,6 +32,8 @@ namespace Tgstation.Server.Tests.Instance
readonly TaskCompletionSource bridgeTestsTcs;
readonly ushort serverPort;
bool chunksProcessed = false;
public TestBridgeHandler(TaskCompletionSource tcs, ILogger<TestBridgeHandler> logger, ushort serverPort)
: base(logger)
{
@@ -41,13 +41,28 @@ namespace Tgstation.Server.Tests.Instance
this.serverPort = serverPort;
}
public override async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
public async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
{
try
{
Assert.AreEqual(DMApiParameters.AccessIdentifier, parameters.AccessIdentifier);
if (parameters.CommandType == BridgeCommandType.Chunk)
return await ProcessBridgeChunk(parameters.Chunk, cancellationToken);
return await ProcessChunk<BridgeParameters, BridgeResponse>(
(parameters, cancellationToken) =>
{
chunksProcessed = true;
return ProcessBridgeRequest(parameters, cancellationToken);
},
error =>
{
bridgeTestsTcs.SetException(new Exception(error));
return new BridgeResponse
{
ErrorMessage = error,
};
},
parameters.Chunk,
cancellationToken);
Assert.AreEqual((BridgeCommandType)0, parameters.CommandType);
Assert.IsNotNull(parameters.ChatMessage?.Text);
@@ -57,6 +72,7 @@ namespace Tgstation.Server.Tests.Instance
Assert.IsFalse(String.IsNullOrWhiteSpace(coreMessage));
if (coreMessage == "done")
{
Assert.IsTrue(chunksProcessed);
Assert.AreEqual(DMApiConstants.MaximumBridgeRequestLength, lastBridgeRequestSize);
Assert.AreEqual(new string('a', (int)(DMApiConstants.MaximumBridgeRequestLength * 3)), splits[1]);
+2 -8
View File
@@ -139,9 +139,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "v5", "v5", "{FAEAD3B5-2EAB-
ProjectSection(SolutionItems) = preProject
src\DMAPI\tgs\v5\api.dm = src\DMAPI\tgs\v5\api.dm
src\DMAPI\tgs\v5\bridge.dm = src\DMAPI\tgs\v5\bridge.dm
src\DMAPI\tgs\v5\chunking.dm = src\DMAPI\tgs\v5\chunking.dm
src\DMAPI\tgs\v5\commands.dm = src\DMAPI\tgs\v5\commands.dm
src\DMAPI\tgs\v5\README.md = src\DMAPI\tgs\v5\README.md
src\DMAPI\tgs\v5\serializers.dm = src\DMAPI\tgs\v5\serializers.dm
src\DMAPI\tgs\v5\topic.dm = src\DMAPI\tgs\v5\topic.dm
src\DMAPI\tgs\v5\undefs.dm = src\DMAPI\tgs\v5\undefs.dm
src\DMAPI\tgs\v5\_defines.dm = src\DMAPI\tgs\v5\_defines.dm
src\DMAPI\tgs\v5\__interop_version.dm = src\DMAPI\tgs\v5\__interop_version.dm
@@ -174,13 +176,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildFail", "BuildFail", "{
tests\DMAPI\BuildFail\Test.dm = tests\DMAPI\BuildFail\Test.dm
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{28CDEB8F-2B2A-47A2-985B-5E2487E8D096}"
ProjectSection(SolutionItems) = preProject
.github\workflows\artifact-cleanup.yml = .github\workflows\artifact-cleanup.yml
.github\workflows\ci-suite.yml = .github\workflows\ci-suite.yml
.github\workflows\stable-merge.yml = .github\workflows\stable-merge.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{CFFD7992-E73A-4D1F-9D7A-C817C07B7BEB}"
ProjectSection(SolutionItems) = preProject
.github\ISSUE_TEMPLATE\bug-report.md = .github\ISSUE_TEMPLATE\bug-report.md
@@ -360,7 +355,6 @@ Global
{7B8FC2AF-1B64-4A89-8480-F0FE360DC9EC} = {82066812-6C73-4360-943B-B23F2F491261}
{F32B9514-AAD9-429D-841A-ED810FC2598C} = {82066812-6C73-4360-943B-B23F2F491261}
{103C61AB-67D6-46FE-AA47-CC633B88EE0F} = {82066812-6C73-4360-943B-B23F2F491261}
{28CDEB8F-2B2A-47A2-985B-5E2487E8D096} = {E82104F4-F5C4-4786-ACD4-B635166CDB21}
{CFFD7992-E73A-4D1F-9D7A-C817C07B7BEB} = {E82104F4-F5C4-4786-ACD4-B635166CDB21}
{5813CC33-B16C-485D-A74D-20204DDF6542} = {316141B0-CD21-4769-A013-D53DA9B9EC09}
{CE499888-B22B-457C-891E-0EA9DC317228} = {A55C1117-5808-4AB2-BEA6-4D4A3E66A2F2}