diff --git a/build/Version.props b/build/Version.props index 26f8009ffe..eca4607a50 100644 --- a/build/Version.props +++ b/build/Version.props @@ -8,8 +8,8 @@ 9.9.0 10.3.0 11.3.0 - 6.3.1 - 5.5.0 + 6.4.0 + 5.6.0 1.2.1 1.2.1 1.0.1 diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index 80df9d1d54..855fdc4285 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -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. diff --git a/src/DMAPI/tgs/includes.dm b/src/DMAPI/tgs/includes.dm index bf91aa6006..23b714f9d0 100644 --- a/src/DMAPI/tgs/includes.dm +++ b/src/DMAPI/tgs/includes.dm @@ -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" diff --git a/src/DMAPI/tgs/v5/README.md b/src/DMAPI/tgs/v5/README.md index 7c65ecf776..a8a0c748e7 100644 --- a/src/DMAPI/tgs/v5/README.md +++ b/src/DMAPI/tgs/v5/README.md @@ -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`. diff --git a/src/DMAPI/tgs/v5/__interop_version.dm b/src/DMAPI/tgs/v5/__interop_version.dm index d0ac7e92ea..6ef7c86ef7 100644 --- a/src/DMAPI/tgs/v5/__interop_version.dm +++ b/src/DMAPI/tgs/v5/__interop_version.dm @@ -1 +1 @@ -"5.5.0" +"5.6.0" diff --git a/src/DMAPI/tgs/v5/_defines.dm b/src/DMAPI/tgs/v5/_defines.dm index 580c40bca0..a3f949081f 100644 --- a/src/DMAPI/tgs/v5/_defines.dm +++ b/src/DMAPI/tgs/v5/_defines.dm @@ -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" diff --git a/src/DMAPI/tgs/v5/api.dm b/src/DMAPI/tgs/v5/api.dm index 15d5bb03e2..91f4d73990 100644 --- a/src/DMAPI/tgs/v5/api.dm +++ b/src/DMAPI/tgs/v5/api.dm @@ -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) diff --git a/src/DMAPI/tgs/v5/bridge.dm b/src/DMAPI/tgs/v5/bridge.dm index d2e7bbc395..df4825f89e 100644 --- a/src/DMAPI/tgs/v5/bridge.dm +++ b/src/DMAPI/tgs/v5/bridge.dm @@ -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 diff --git a/src/DMAPI/tgs/v5/chunking.dm b/src/DMAPI/tgs/v5/chunking.dm new file mode 100644 index 0000000000..af4cd6cc80 --- /dev/null +++ b/src/DMAPI/tgs/v5/chunking.dm @@ -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 diff --git a/src/DMAPI/tgs/v5/topic.dm b/src/DMAPI/tgs/v5/topic.dm new file mode 100644 index 0000000000..c54ee810f7 --- /dev/null +++ b/src/DMAPI/tgs/v5/topic.dm @@ -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]") diff --git a/src/DMAPI/tgs/v5/undefs.dm b/src/DMAPI/tgs/v5/undefs.dm index 6209945372..2e3b7ae771 100644 --- a/src/DMAPI/tgs/v5/undefs.dm +++ b/src/DMAPI/tgs/v5/undefs.dm @@ -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 diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeRequestChunker.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeRequestChunker.cs deleted file mode 100644 index 9ce6d7b443..0000000000 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeRequestChunker.cs +++ /dev/null @@ -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 -{ - /// - /// Processes chunked bridge requests. - /// - abstract class BridgeRequestChunker : IBridgeDispatcher - { - /// - /// The cache of chunked bridge requests. - /// - /// If the DMAPI is erroring, this can present a memory leak. Worth expiring entries if they aren't completed after some minutes. - readonly Dictionary> bridgeChunks; - - /// - /// The for the . - /// - protected ILogger Logger { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - protected BridgeRequestChunker(ILogger logger) - { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - bridgeChunks = new Dictionary>(); - } - - /// - public abstract Task ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken); - - /// - /// Process a given . - /// - /// The . - /// The for the operation. - /// A resulting in the for the chunked request. - protected async Task 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(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(); - 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(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); - } - - /// - /// Create and logs an errored . - /// - /// The error message. - /// If should be written to the . - /// A new errored . - protected BridgeResponse BridgeError(string message, bool log = true) - { - if (log) - Logger.LogWarning("Bridge processing error: {errorMessage}", message); - - return new BridgeResponse - { - ErrorMessage = message, - }; - } - } -} diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs index 68c1adbea7..f7f358d612 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Components.Interop.Bridge /// /// A response to a bridge request. /// - public class BridgeResponse : DMApiResponse + public class BridgeResponse : DMApiResponse, IMissingPayloadsCommunication { /// /// The new port for requests. diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/ChunkData.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/ChunkData.cs deleted file mode 100644 index e0c0204d2c..0000000000 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/ChunkData.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Tgstation.Server.Host.Components.Interop.Bridge -{ - /// - /// A packet of a split serialized set of . - /// - public sealed class ChunkData : ChunkedRequestInfo - { - /// - /// The sequence ID of the chunk. - /// - public uint SequenceId { get; set; } - - /// - /// The partial JSON payload of the chunk. - /// - public string Payload { get; set; } - } -} diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/ChunkedRequestInfo.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/ChunkedRequestInfo.cs deleted file mode 100644 index 1246f37a19..0000000000 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/ChunkedRequestInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Tgstation.Server.Host.Components.Interop.Bridge -{ - /// - /// Information about a chunked bridge request. - /// - public abstract class ChunkedRequestInfo - { - /// - /// The ID of the full request to differentiate different chunkings. - /// - public uint PayloadId { get; set; } - - /// - /// The total number of chunks in the request. - /// - public uint TotalChunks { get; set; } - } -} diff --git a/src/Tgstation.Server.Host/Components/Interop/ChunkData.cs b/src/Tgstation.Server.Host/Components/Interop/ChunkData.cs new file mode 100644 index 0000000000..ee6122d764 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/ChunkData.cs @@ -0,0 +1,19 @@ +namespace Tgstation.Server.Host.Components.Interop +{ + /// + /// A packet of a split serialized set of data. + /// + public sealed class ChunkData : ChunkSetInfo + { + /// + /// The sequence ID of the chunk. + /// + /// Always zero indexed. Nullable to prevent default value omission. + public uint? SequenceId { get; set; } + + /// + /// The partial JSON payload of the chunk. + /// + public string Payload { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/ChunkSetInfo.cs b/src/Tgstation.Server.Host/Components/Interop/ChunkSetInfo.cs new file mode 100644 index 0000000000..6d6a23c4ae --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/ChunkSetInfo.cs @@ -0,0 +1,16 @@ +namespace Tgstation.Server.Host.Components.Interop +{ + /// + /// Information about a chunked bridge request. + /// + public abstract class ChunkSetInfo : IChunkPayloadId + { + /// + public uint? PayloadId { get; set; } + + /// + /// The total number of chunks in the request. + /// + public uint TotalChunks { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/Chunker.cs b/src/Tgstation.Server.Host/Components/Interop/Chunker.cs new file mode 100644 index 0000000000..7363f3aee8 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/Chunker.cs @@ -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 +{ + /// + /// Class that deserializes chunked interop payloads. + /// + abstract class Chunker + { + /// + /// Gets a payload ID for use in a new . + /// + 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; + } + } + + /// + /// The cache of chunked communications. + /// + /// If the DMAPI is erroring, this can present a memory leak. Worth expiring entries if they aren't completed after some minutes. + readonly Dictionary> chunkSets; + + /// + /// The highest payload ID value seen. + /// + uint highestSeenPayloadId; + + /// + /// The for the . + /// + protected ILogger Logger { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + protected Chunker(ILogger logger) + { + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + chunkSets = new Dictionary>(); + } + + /// + /// Process a given . + /// + /// The of communication that was chunked. + /// The of expected. + /// The callback that receives the completed . + /// The callback that generates a for a given error. + /// The . + /// The for the operation. + /// A resulting in the for the chunked request. + protected async Task ProcessChunk( + Func> completionCallback, + Func 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(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(); + 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(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); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/IChunkPayloadId.cs b/src/Tgstation.Server.Host/Components/Interop/IChunkPayloadId.cs new file mode 100644 index 0000000000..173a854581 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/IChunkPayloadId.cs @@ -0,0 +1,14 @@ +namespace Tgstation.Server.Host.Components.Interop +{ + /// + /// Represents the payload ID of a set of chunked data. + /// + public interface IChunkPayloadId + { + /// + /// The ID of the full request to differentiate different chunkings. + /// + /// Nullable to prevent default value omission. + uint? PayloadId { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/IMissingPayloadsCommunication.cs b/src/Tgstation.Server.Host/Components/Interop/IMissingPayloadsCommunication.cs new file mode 100644 index 0000000000..392cd7eecb --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/IMissingPayloadsCommunication.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Components.Interop +{ + /// + /// A communication that can be missing chunk payloads. + /// + interface IMissingPayloadsCommunication + { + /// + /// The s missing from a chunked request. + /// + IReadOnlyCollection MissingChunks { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/ChunkedTopicParameters.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/ChunkedTopicParameters.cs new file mode 100644 index 0000000000..79353a66bb --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/ChunkedTopicParameters.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Components.Interop.Topic +{ + /// + /// for . + /// + sealed class ChunkedTopicParameters : TopicParameters, IMissingPayloadsCommunication, IChunkPayloadId + { + /// + public IReadOnlyCollection MissingChunks { get; set; } + + /// + public uint? PayloadId { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public ChunkedTopicParameters() + : base(TopicCommandType.ReceiveChunk) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs index 83a71feaf3..e44e1a925e 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs @@ -52,5 +52,15 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// Notify the server of a reattach and potentially new version. /// ServerRestarted, + + /// + /// Part of a larger topic. + /// + SendChunk, + + /// + /// Receive additional data for a previous response. + /// + ReceiveChunk, } } diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs index 1deaac3848..6e3e604d7c 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// /// Parameters for a topic request. /// - sealed class TopicParameters : DMApiParameters + class TopicParameters : DMApiParameters { /// /// The . @@ -50,6 +50,11 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// public Version NewServerVersion { get; } + /// + /// The for a partial request. + /// + public ChunkData Chunk { get; } + /// /// Initializes a new instance of the class. /// @@ -122,6 +127,16 @@ namespace Tgstation.Server.Host.Components.Interop.Topic NewPort = serverPort; } + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public TopicParameters(ChunkData chunk) + : this(TopicCommandType.SendChunk) + { + Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk)); + } + /// /// Initializes a new instance of the class. /// @@ -135,7 +150,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// Initializes a new instance of the class. /// /// The value of . - TopicParameters(TopicCommandType commandType) + protected TopicParameters(TopicCommandType commandType) { CommandType = commandType; } diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicResponse.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicResponse.cs index 7a67104155..f108aba543 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicResponse.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicResponse.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// /// A response to a topic request. /// - sealed class TopicResponse : DMApiResponse + sealed class TopicResponse : DMApiResponse, IMissingPayloadsCommunication { /// /// The text to reply with as the result of a request, if any. Deprecated circa Interop 5.4.0. @@ -28,5 +28,13 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// The DMAPI s for requests. /// public ICollection CustomCommands { get; set; } + + /// + /// The for a partial response. + /// + public ChunkData Chunk { get; set; } + + /// + public IReadOnlyCollection MissingChunks { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/Session/ISessionController.cs b/src/Tgstation.Server.Host/Components/Session/ISessionController.cs index 343e7eec37..21b45dc434 100644 --- a/src/Tgstation.Server.Host/Components/Session/ISessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/ISessionController.cs @@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Components.Session /// The to send. /// The for the operation. /// A resulting in the of /world/Topic(). - Task SendCommand(TopicParameters parameters, CancellationToken cancellationToken); + Task SendCommand(TopicParameters parameters, CancellationToken cancellationToken); /// /// Causes the world to start listening on a . diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index 723c8626ac..bb7f7345d8 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -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 { /// - sealed class SessionController : BridgeRequestChunker, ISessionController, IBridgeHandler, IChannelSink + sealed class SessionController : Chunker, ISessionController, IBridgeHandler, IChannelSink { /// public DMApiParameters DMApiParameters => ReattachInformation; @@ -91,9 +93,9 @@ namespace Tgstation.Server.Host.Components.Session readonly CancellationTokenSource reattachTopicCts; /// - /// The for the . + /// The for the . /// - readonly ITopicClient byondTopicSender; + readonly global::Byond.TopicSender.ITopicClient byondTopicSender; /// /// The for the . @@ -177,7 +179,7 @@ namespace Tgstation.Server.Host.Components.Session /// The value of . /// The value of . /// The for the . - /// The value of . + /// The value of . /// The returning a to be run after the ends. /// The optional time to wait before failing the . /// If this is a reattached session. @@ -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 } /// - public override async Task ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken) + public async Task ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken) { if (parameters == null) throw new ArgumentNullException(nameof(parameters)); @@ -319,7 +321,7 @@ namespace Tgstation.Server.Host.Components.Session } /// - public async Task SendCommand(TopicParameters parameters, CancellationToken cancellationToken) + public async Task 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(topicReturn, DMApiConstants.SerializerSettings); - if (interopResponse.ErrorMessage != null) + var nextRequest = await ProcessChunk( + (completedResponse, cancellationToken) => + { + fullResponse = completedResponse; + return Task.FromResult(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; } /// @@ -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; } /// @@ -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(ProcessBridgeRequest, BridgeError, parameters.Chunk, cancellationToken); case null: return BridgeError("Missing commandType!"); default: @@ -707,5 +720,198 @@ namespace Tgstation.Server.Host.Components.Session return response; } + + /// + /// Log and return a for a given . + /// + /// The error message. + /// A new errored . + BridgeResponse BridgeError(string message) + { + Logger.LogWarning("Bridge request chunking error: {message}", message); + return new BridgeResponse + { + ErrorMessage = message, + }; + } + + /// + /// Send a topic request for given to DreamDaemon, chunking it if necessary. + /// + /// The to send. + /// The for the operation. + /// A resulting in the of the topic request. + async Task 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 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(); + 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; + } + + /// + /// Generates a query string for a given set of . + /// + /// The to serialize. + /// The intermediate JSON prior to URL encoding. + /// The query string for the given . + 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; + } + + /// + /// Send a given to DreamDaemon's /world/Topic. + /// + /// The sanitized topic query string to send. + /// The for the operation. + /// A resulting in the of the topic request. + async Task 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(topicReturn, DMApiConstants.SerializerSettings); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Invalid interop response: {topicReturnString}", topicReturn); + } + + return new CombinedTopicResponse(byondResponse, interopResponse); + } } } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index 4d9330aaeb..a708244fe1 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -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 /// /// Handle any in a given topic . /// - /// The . - void HandleChatResponses(CombinedTopicResponse result) + /// The . + 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 diff --git a/tests/Tgstation.Server.Tests/Instance/TestBridgeHandler.cs b/tests/Tgstation.Server.Tests/Instance/TestBridgeHandler.cs index b2c07e33cf..ef751274b9 100644 --- a/tests/Tgstation.Server.Tests/Instance/TestBridgeHandler.cs +++ b/tests/Tgstation.Server.Tests/Instance/TestBridgeHandler.cs @@ -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 logger, ushort serverPort) : base(logger) { @@ -41,13 +41,28 @@ namespace Tgstation.Server.Tests.Instance this.serverPort = serverPort; } - public override async Task ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken) + public async Task 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( + (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]); diff --git a/tgstation-server.sln b/tgstation-server.sln index 8d53fc25c0..6128dc5188 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -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}