diff --git a/build/Version.props b/build/Version.props index 5f87b4c3ba..d5b05ce320 100644 --- a/build/Version.props +++ b/build/Version.props @@ -10,7 +10,7 @@ 13.1.0 15.1.0 7.1.0 - 5.8.0 + 5.9.0 1.4.1 1.2.1 2.0.0 diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index 1d7f7d02f8..dc49d2c6f0 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -496,6 +496,16 @@ /// Returns a list of connected [/datum/tgs_chat_channel]s if TGS is present, null otherwise. This function may sleep if the call to [/world/proc/TgsNew] is sleeping! /world/proc/TgsChatChannelInfo() return + +/** + * Trigger an event in TGS. Requires TGS version >= 6.3.0. Returns [TRUE] if the event was triggered successfully, [FALSE] otherwise. This function may sleep! + * + * event_name - The name of the event to trigger + * parameters - Optional list of string parameters to pass as arguments to the event script. The first parameter passed to a script will always be the running game's directory followed by these parameters. + * wait_for_completion - If set, this function will not return until the event has run to completion. + */ +/world/proc/TgsTriggerEvent(event_name, list/parameters, wait_for_completion = FALSE) + return /* The MIT License diff --git a/src/DMAPI/tgs/core/core.dm b/src/DMAPI/tgs/core/core.dm index 8be96f2740..15622228e9 100644 --- a/src/DMAPI/tgs/core/core.dm +++ b/src/DMAPI/tgs/core/core.dm @@ -166,3 +166,11 @@ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs) if(api) return api.Visibility() + +/world/TgsTriggerEvent(event_name, list/parameters, wait_for_completion = FALSE) + var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs) + if(api) + if(!istype(parameters, /list)) + parameters = list() + + return api.TriggerEvent(event_name, parameters, wait_for_completion) diff --git a/src/DMAPI/tgs/core/datum.dm b/src/DMAPI/tgs/core/datum.dm index 07ce3b6845..fefca3af2f 100644 --- a/src/DMAPI/tgs/core/datum.dm +++ b/src/DMAPI/tgs/core/datum.dm @@ -69,3 +69,6 @@ TGS_PROTECT_DATUM(/datum/tgs_api) /datum/tgs_api/proc/Visibility() return TGS_UNIMPLEMENTED + +/datum/tgs_api/proc/TriggerEvent(event_name, list/parameters, wait_for_completion) + return FALSE diff --git a/src/DMAPI/tgs/v5/__interop_version.dm b/src/DMAPI/tgs/v5/__interop_version.dm index 616263098f..f4806f7adb 100644 --- a/src/DMAPI/tgs/v5/__interop_version.dm +++ b/src/DMAPI/tgs/v5/__interop_version.dm @@ -1 +1 @@ -"5.8.0" +"5.9.0" diff --git a/src/DMAPI/tgs/v5/_defines.dm b/src/DMAPI/tgs/v5/_defines.dm index 1c7d67d20c..92c7a8388a 100644 --- a/src/DMAPI/tgs/v5/_defines.dm +++ b/src/DMAPI/tgs/v5/_defines.dm @@ -14,6 +14,7 @@ #define DMAPI5_BRIDGE_COMMAND_KILL 4 #define DMAPI5_BRIDGE_COMMAND_CHAT_SEND 5 #define DMAPI5_BRIDGE_COMMAND_CHUNK 6 +#define DMAPI5_BRIDGE_COMMAND_EVENT 7 #define DMAPI5_PARAMETER_ACCESS_IDENTIFIER "accessIdentifier" #define DMAPI5_PARAMETER_CUSTOM_COMMANDS "customCommands" @@ -34,6 +35,7 @@ #define DMAPI5_BRIDGE_PARAMETER_VERSION "version" #define DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE "chatMessage" #define DMAPI5_BRIDGE_PARAMETER_MINIMUM_SECURITY_LEVEL "minimumSecurityLevel" +#define DMAPI5_BRIDGE_PARAMETER_EVENT_INVOCATION "eventInvocation" #define DMAPI5_BRIDGE_RESPONSE_NEW_PORT "newPort" #define DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION "runtimeInformation" @@ -81,6 +83,7 @@ #define DMAPI5_TOPIC_COMMAND_SEND_CHUNK 9 #define DMAPI5_TOPIC_COMMAND_RECEIVE_CHUNK 10 #define DMAPI5_TOPIC_COMMAND_RECEIVE_BROADCAST 11 +#define DMAPI5_TOPIC_COMMAND_COMPLETE_EVENT 12 #define DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE "commandType" #define DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND "chatCommand" @@ -116,3 +119,9 @@ #define DMAPI5_CUSTOM_CHAT_COMMAND_NAME "name" #define DMAPI5_CUSTOM_CHAT_COMMAND_HELP_TEXT "helpText" #define DMAPI5_CUSTOM_CHAT_COMMAND_ADMIN_ONLY "adminOnly" + +#define DMAPI5_EVENT_ID "eventId" + +#define DMAPI5_EVENT_INVOCATION_NAME "eventName" +#define DMAPI5_EVENT_INVOCATION_PARAMETERS "parameters" +#define DMAPI5_EVENT_INVOCATION_NOTIFY_COMPLETION "notifyCompletion" diff --git a/src/DMAPI/tgs/v5/api.dm b/src/DMAPI/tgs/v5/api.dm index a5c064a8ea..32d09544ea 100644 --- a/src/DMAPI/tgs/v5/api.dm +++ b/src/DMAPI/tgs/v5/api.dm @@ -27,6 +27,8 @@ var/chunked_requests = 0 var/list/chunked_topics = list() + var/list/pending_events = list() + var/detached = FALSE /datum/tgs_api/v5/New() @@ -249,6 +251,41 @@ WaitForReattach(TRUE) return chat_channels.Copy() +/datum/tgs_api/v5/TriggerEvent(event_name, list/parameters, wait_for_completion) + RequireInitialBridgeResponse() + WaitForReattach(TRUE) + + if(interop_version.minor < 9) + TGS_WARNING_LOG("Interop version too low for custom events!") + return FALSE + + var/str_parameters = list() + for(var/i in parameters) + str_parameters += "[i]" + + var/list/response = Bridge(DMAPI5_BRIDGE_COMMAND_EVENT, list(DMAPI5_BRIDGE_PARAMETER_EVENT_INVOCATION = list(DMAPI5_EVENT_INVOCATION_NAME = event_name, DMAPI5_EVENT_INVOCATION_PARAMETERS = str_parameters, DMAPI5_EVENT_INVOCATION_NOTIFY_COMPLETION = wait_for_completion))) + if(!response) + return FALSE + + var/event_id = response[DMAPI5_EVENT_ID] + if(!event_id) + return FALSE + + TGS_DEBUG_LOG("Created event ID: [event_id]") + if(!wait_for_completion) + return TRUE + + TGS_DEBUG_LOG("Waiting for completion of event ID: [event_id]") + pending_events[event_id] = TRUE + + do + sleep(1) + while(pending_events[event_id]) + + TGS_DEBUG_LOG("Completed wait on event ID: [event_id]") + + return TRUE + /datum/tgs_api/v5/proc/DecodeChannels(chat_update_json) TGS_DEBUG_LOG("DecodeChannels()") var/list/chat_channels_json = chat_update_json[DMAPI5_CHAT_UPDATE_CHANNELS] diff --git a/src/DMAPI/tgs/v5/topic.dm b/src/DMAPI/tgs/v5/topic.dm index 05e6c4e1b2..b13f83f82c 100644 --- a/src/DMAPI/tgs/v5/topic.dm +++ b/src/DMAPI/tgs/v5/topic.dm @@ -176,6 +176,9 @@ var/list/reattach_response = TopicResponse(error_message) reattach_response[DMAPI5_PARAMETER_CUSTOM_COMMANDS] = ListCustomCommands() reattach_response[DMAPI5_PARAMETER_TOPIC_PORT] = GetTopicPort() + + pending_events.Cut() + return reattach_response if(DMAPI5_TOPIC_COMMAND_SEND_CHUNK) @@ -276,6 +279,15 @@ TGS_WORLD_ANNOUNCE(message) return TopicResponse() + if(DMAPI5_TOPIC_COMMAND_COMPLETE_EVENT) + var/event_id = topic_parameters[DMAPI5_EVENT_ID] + if (!istext(event_id)) + return TopicResponse("Invalid or missing [DMAPI5_EVENT_ID]") + + TGS_DEBUG_LOG("Completing event ID [event_id]...") + pending_events -= event_id + return TopicResponse() + return TopicResponse("Unknown command: [command]") /datum/tgs_api/v5/proc/WorldBroadcast(message) diff --git a/src/DMAPI/tgs/v5/undefs.dm b/src/DMAPI/tgs/v5/undefs.dm index d531d4b7b9..237207fdfd 100644 --- a/src/DMAPI/tgs/v5/undefs.dm +++ b/src/DMAPI/tgs/v5/undefs.dm @@ -14,6 +14,7 @@ #undef DMAPI5_BRIDGE_COMMAND_KILL #undef DMAPI5_BRIDGE_COMMAND_CHAT_SEND #undef DMAPI5_BRIDGE_COMMAND_CHUNK +#undef DMAPI5_BRIDGE_COMMAND_EVENT #undef DMAPI5_PARAMETER_ACCESS_IDENTIFIER #undef DMAPI5_PARAMETER_CUSTOM_COMMANDS @@ -34,6 +35,7 @@ #undef DMAPI5_BRIDGE_PARAMETER_VERSION #undef DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE #undef DMAPI5_BRIDGE_PARAMETER_MINIMUM_SECURITY_LEVEL +#undef DMAPI5_BRIDGE_PARAMETER_EVENT_INVOCATION #undef DMAPI5_BRIDGE_RESPONSE_NEW_PORT #undef DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION @@ -81,6 +83,7 @@ #undef DMAPI5_TOPIC_COMMAND_SEND_CHUNK #undef DMAPI5_TOPIC_COMMAND_RECEIVE_CHUNK #undef DMAPI5_TOPIC_COMMAND_RECEIVE_BROADCAST +#undef DMAPI5_TOPIC_COMMAND_COMPLETE_EVENT #undef DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE #undef DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND @@ -116,3 +119,9 @@ #undef DMAPI5_CUSTOM_CHAT_COMMAND_NAME #undef DMAPI5_CUSTOM_CHAT_COMMAND_HELP_TEXT #undef DMAPI5_CUSTOM_CHAT_COMMAND_ADMIN_ONLY + +#undef DMAPI5_EVENT_ID + +#undef DMAPI5_EVENT_INVOCATION_NAME +#undef DMAPI5_EVENT_INVOCATION_PARAMETERS +#undef DMAPI5_EVENT_INVOCATION_NOTIFY_COMPLETION diff --git a/src/Tgstation.Server.Host/Components/Deployment/IDmbProvider.cs b/src/Tgstation.Server.Host/Components/Deployment/IDmbProvider.cs index 820796c318..7d5f0fd6a9 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/IDmbProvider.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/IDmbProvider.cs @@ -15,7 +15,7 @@ namespace Tgstation.Server.Host.Components.Deployment string DmbName { get; } /// - /// The primary game directory with a trailing directory separator. + /// The primary game directory. /// string Directory { get; } diff --git a/src/Tgstation.Server.Host/Components/Events/EventConsumer.cs b/src/Tgstation.Server.Host/Components/Events/EventConsumer.cs index 559e584a27..fa98e56607 100644 --- a/src/Tgstation.Server.Host/Components/Events/EventConsumer.cs +++ b/src/Tgstation.Server.Host/Components/Events/EventConsumer.cs @@ -30,6 +30,10 @@ namespace Tgstation.Server.Host.Components.Events this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } + /// + public ValueTask? HandleCustomEvent(string eventName, IEnumerable parameters, CancellationToken cancellationToken) + => configuration.HandleCustomEvent(eventName, parameters, cancellationToken); + /// public async ValueTask HandleEvent(EventType eventType, IEnumerable parameters, bool deploymentPipeline, CancellationToken cancellationToken) { diff --git a/src/Tgstation.Server.Host/Components/Events/EventScriptAttribute.cs b/src/Tgstation.Server.Host/Components/Events/EventScriptAttribute.cs index ba93d34cf9..7d772c2318 100644 --- a/src/Tgstation.Server.Host/Components/Events/EventScriptAttribute.cs +++ b/src/Tgstation.Server.Host/Components/Events/EventScriptAttribute.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; namespace Tgstation.Server.Host.Components.Events { @@ -12,7 +11,7 @@ namespace Tgstation.Server.Host.Components.Events /// /// The name and order of the scripts the event script the runs. /// - public IReadOnlyList ScriptNames { get; } + public string[] ScriptNames { get; } /// /// Initializes a new instance of the class. diff --git a/src/Tgstation.Server.Host/Components/Events/IEventConsumer.cs b/src/Tgstation.Server.Host/Components/Events/IEventConsumer.cs index 4d268011f1..a12547e8c2 100644 --- a/src/Tgstation.Server.Host/Components/Events/IEventConsumer.cs +++ b/src/Tgstation.Server.Host/Components/Events/IEventConsumer.cs @@ -18,5 +18,14 @@ namespace Tgstation.Server.Host.Components.Events /// The for the operation. /// A representing the running operation. ValueTask HandleEvent(EventType eventType, IEnumerable parameters, bool deploymentPipeline, CancellationToken cancellationToken); + + /// + /// Handles a given custom event. + /// + /// The name of the event. + /// An of parameters for the event. + /// The for the operation. + /// A representing the running operation if the event was triggered successfully, if it matched a TGS event and wasn't executed. + ValueTask? HandleCustomEvent(string eventName, IEnumerable parameters, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Events/NoopEventConsumer.cs b/src/Tgstation.Server.Host/Components/Events/NoopEventConsumer.cs index dde777572a..880a45c9ef 100644 --- a/src/Tgstation.Server.Host/Components/Events/NoopEventConsumer.cs +++ b/src/Tgstation.Server.Host/Components/Events/NoopEventConsumer.cs @@ -12,5 +12,9 @@ namespace Tgstation.Server.Host.Components.Events /// public ValueTask HandleEvent(EventType eventType, IEnumerable parameters, bool deploymentPipeline, CancellationToken cancellationToken) => ValueTask.CompletedTask; + + /// + public ValueTask? HandleCustomEvent(string eventName, IEnumerable parameters, CancellationToken cancellationToken) + => ValueTask.CompletedTask; } } diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeCommandType.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeCommandType.cs index 8c18f74c39..287e9aceda 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeCommandType.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeCommandType.cs @@ -39,5 +39,10 @@ /// DreamDaemon attempting to send a longer bridge message. /// Chunk, + + /// + /// DreamDaemon requesting a custom event to be triggered. + /// + Event, } } diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeParameters.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeParameters.cs index 1240bc81cb..228c467768 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeParameters.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeParameters.cs @@ -51,6 +51,11 @@ namespace Tgstation.Server.Host.Components.Interop.Bridge /// public ushort? TopicPort { get; set; } + /// + /// The being triggered. + /// + public CustomEventInvocation? EventInvocation { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs index 620241fafb..da3a3bb563 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Bridge/BridgeResponse.cs @@ -21,5 +21,10 @@ namespace Tgstation.Server.Host.Components.Interop.Bridge /// The s missing from a chunked request. /// public IReadOnlyCollection? MissingChunks { get; set; } + + /// + /// The triggered event ID for requests. + /// + public string? EventId { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/CustomEventInvocation.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/CustomEventInvocation.cs new file mode 100644 index 0000000000..afb6ab66cf --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Interop/Bridge/CustomEventInvocation.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Components.Interop.Bridge +{ + /// + /// Parameters for invoking a custom event. + /// + public sealed class CustomEventInvocation + { + /// + /// The name of the event being invoked. + /// + public string? EventName { get; set; } + + /// + /// The parameters for the invoked event. + /// + public ICollection? Parameters { get; set; } + + /// + /// If the DMAPI should be notified when the event compeletes. + /// + public bool? NotifyCompletion { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/EventNotification.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/EventNotification.cs index 75eb3bc9f4..1284940b12 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Topic/EventNotification.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/EventNotification.cs @@ -15,12 +15,12 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// The triggered. /// /// Nullable to prevent ignoring when serializing. - public EventType? Type { get; } + public EventType Type { get; } /// /// The set of parameters. /// - public IReadOnlyCollection Parameters { get; } + public IReadOnlyCollection? Parameters { get; } /// /// Initializes a new instance of the class. diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs index 286c605d07..c7d21332c3 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicCommandType.cs @@ -67,5 +67,10 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// Sending a broadcast message. /// Broadcast, + + /// + /// Notifying about the completion of a custom event. + /// + CompleteEvent, } } diff --git a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs index bdc8405b02..1b2745ab6a 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Topic/TopicParameters.cs @@ -62,6 +62,11 @@ namespace Tgstation.Server.Host.Components.Interop.Topic /// public ChunkData? Chunk { get; } + /// + /// The completed custom event ID. + /// + public string? EventId { get; set; } + /// /// Whether or not the constitute a priority request. /// @@ -74,6 +79,7 @@ namespace Tgstation.Server.Host.Components.Interop.Topic or TopicCommandType.InstanceRenamed or TopicCommandType.ChatChannelsUpdate or TopicCommandType.Broadcast + or TopicCommandType.CompleteEvent or TopicCommandType.ServerRestarted => true, TopicCommandType.ChatCommand or TopicCommandType.HealthCheck @@ -174,6 +180,16 @@ namespace Tgstation.Server.Host.Components.Interop.Topic Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk)); } + /// + /// Initializes a new instance of the class. + /// + /// The containig the value of . + public TopicParameters(Guid eventId) + : this(TopicCommandType.CompleteEvent) + { + EventId = eventId.ToString(); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index 110506c6e2..e32c251eb5 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -19,6 +19,7 @@ using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Chat.Commands; using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Engine; +using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Components.Interop.Bridge; using Tgstation.Server.Host.Components.Interop.Topic; @@ -159,6 +160,11 @@ namespace Tgstation.Server.Host.Components.Session /// readonly IDotnetDumpService dotnetDumpService; + /// + /// The for the . + /// + readonly IEventConsumer eventConsumer; + /// /// The that completes when DD makes it's first bridge request. /// @@ -170,9 +176,9 @@ namespace Tgstation.Server.Host.Components.Session readonly Api.Models.Instance metadata; /// - /// A used for the topic send operation made on reattaching. + /// A used for tasks that should not exceed the lifetime of the session. /// - readonly CancellationTokenSource reattachTopicCts; + readonly CancellationTokenSource sessionDurationCts; /// /// for port updates and . @@ -204,6 +210,11 @@ namespace Tgstation.Server.Host.Components.Session /// volatile Task rebootGate; + /// + /// The representing calls to . + /// + volatile Task customEventProcessingTask; + /// /// for shutting down the server if it is taking too long after validation. /// @@ -248,6 +259,7 @@ namespace Tgstation.Server.Host.Components.Session /// The for the . /// The value of . /// The value of . + /// The value of . /// The value of . /// The returning a to be run after the ends. /// The optional time to wait before failing the . @@ -265,6 +277,7 @@ namespace Tgstation.Server.Host.Components.Session IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, IDotnetDumpService dotnetDumpService, + IEventConsumer eventConsumer, ILogger logger, Func postLifetimeCallback, uint? startupTimeout, @@ -285,6 +298,7 @@ namespace Tgstation.Server.Host.Components.Session this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.dotnetDumpService = dotnetDumpService ?? throw new ArgumentNullException(nameof(dotnetDumpService)); + this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); apiValidationSession = apiValidate; @@ -297,12 +311,13 @@ namespace Tgstation.Server.Host.Components.Session primeTcs = new TaskCompletionSource(); rebootGate = Task.CompletedTask; + customEventProcessingTask = Task.CompletedTask; // Run this asynchronously because we want to try to avoid any effects sending topics to the server while the initial bridge request is processing // It MAY be the source of a DD crash. See this gist https://gist.github.com/Cyberboss/7776bbeff3a957d76affe0eae95c9f14 // Worth further investigation as to if that sequence of events is a reliable crash vector and opening a BYOND bug if it is initialBridgeRequestTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - reattachTopicCts = new CancellationTokenSource(); + sessionDurationCts = new CancellationTokenSource(); TopicSendSemaphore = new FifoSemaphore(); synchronizationLock = new object(); @@ -356,7 +371,7 @@ namespace Tgstation.Server.Host.Components.Session Logger.LogTrace("Disposing..."); - reattachTopicCts.Cancel(); + sessionDurationCts.Cancel(); var cancellationToken = CancellationToken.None; // DCT: None available var semaphoreLockTask = TopicSendSemaphore.Lock(cancellationToken); @@ -381,13 +396,15 @@ namespace Tgstation.Server.Host.Components.Session await regularDmbDisposeTask; chatTrackingContext.Dispose(); - reattachTopicCts.Dispose(); + sessionDurationCts.Dispose(); if (!released) await Lifetime; // finish the async callback (await semaphoreLockTask).Dispose(); TopicSendSemaphore.Dispose(); + + await customEventProcessingTask; } /// @@ -547,7 +564,7 @@ namespace Tgstation.Server.Host.Components.Session assemblyInformationProvider.Version, ReattachInformation.RuntimeInformation!.ServerPort), true, - reattachTopicCts.Token); + sessionDurationCts.Token); if (reattachResponse != null) { @@ -735,6 +752,8 @@ namespace Tgstation.Server.Host.Components.Session break; case BridgeCommandType.Chunk: return await ProcessChunk(ProcessBridgeCommand, BridgeError, parameters.Chunk, cancellationToken); + case BridgeCommandType.Event: + return TriggerCustomEvent(parameters.EventInvocation); case null: return BridgeError("Missing commandType!"); default: @@ -1102,5 +1121,81 @@ namespace Tgstation.Server.Host.Components.Session return fullResponse; } + + /// + /// Trigger a custom event from a given . + /// + /// The . + /// An appropriate . + BridgeResponse TriggerCustomEvent(CustomEventInvocation? invocation) + { + if (invocation == null) + return BridgeError("Missing eventInvocation!"); + + var eventName = invocation.EventName; + if (eventName == null) + return BridgeError("Missing eventName!"); + + var notifyCompletion = invocation.NotifyCompletion; + if (!notifyCompletion.HasValue) + return BridgeError("Missing notifyCompletion!"); + + var eventParams = new List + { + ReattachInformation.Dmb.Directory, + }; + + eventParams.AddRange(invocation + .Parameters? + .Where(param => param != null) + .Cast() + ?? Enumerable.Empty()); + + var eventId = Guid.NewGuid(); + Logger.LogInformation("Triggering custom event \"{eventName}\": {eventId}", eventName, eventId); + + var cancellationToken = sessionDurationCts.Token; + ValueTask? eventTask = eventConsumer.HandleCustomEvent(eventName, eventParams, cancellationToken); + + async Task ProcessEvent() + { + try + { + await eventTask.Value; + + if (notifyCompletion.Value) + await SendCommand( + new TopicParameters(eventId), + cancellationToken); + else + Logger.LogTrace("Finished custom event {eventId}, not sending notification.", eventId); + } + catch (OperationCanceledException ex) + { + Logger.LogDebug(ex, "Custom event invocation {eventId} aborted!", eventId); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Custom event invocation {eventId} errored!", eventId); + } + } + + if (!eventTask.HasValue) + return BridgeError("Event refused to execute due to matching a TGS event!"); + + lock (sessionDurationCts) + { + var previousEventProcessingTask = customEventProcessingTask; + var eventProcessingTask = ProcessEvent(); + customEventProcessingTask = Task.WhenAll(customEventProcessingTask, eventProcessingTask); + } + + return new BridgeResponse + { + EventId = notifyCompletion.Value + ? eventId.ToString() + : null, + }; + } } } diff --git a/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs b/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs index 4c7fab19b9..5e89fe8cb2 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs @@ -355,6 +355,7 @@ namespace Tgstation.Server.Host.Components.Session assemblyInformationProvider, asyncDelayer, dotnetDumpService, + eventConsumer, loggerFactory.CreateLogger(), () => LogDDOutput( process, @@ -446,6 +447,7 @@ namespace Tgstation.Server.Host.Components.Session assemblyInformationProvider, asyncDelayer, dotnetDumpService, + eventConsumer, loggerFactory.CreateLogger(), () => ValueTask.CompletedTask, null, diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs index c77fa7ab49..f25f515a60 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs @@ -71,11 +71,11 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// /// Map of s to the filename of the event scripts they trigger. /// - public static IReadOnlyDictionary> EventTypeScriptFileNameMap { get; } = new Dictionary>( + public static IReadOnlyDictionary EventTypeScriptFileNameMap { get; } = new Dictionary( Enum.GetValues(typeof(EventType)) .Cast() .Select( - eventType => new KeyValuePair>( + eventType => new KeyValuePair( eventType, typeof(EventType) .GetField(eventType.ToString())! @@ -600,70 +600,39 @@ namespace Tgstation.Server.Host.Components.StaticFiles public Task StopAsync(CancellationToken cancellationToken) => EnsureDirectories(cancellationToken); /// - public async ValueTask HandleEvent(EventType eventType, IEnumerable parameters, bool deploymentPipeline, CancellationToken cancellationToken) + public ValueTask HandleEvent(EventType eventType, IEnumerable parameters, bool deploymentPipeline, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(parameters); - await EnsureDirectories(cancellationToken); - if (!EventTypeScriptFileNameMap.TryGetValue(eventType, out var scriptNames)) - return; - - // always execute in serial - using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken)) { - var files = await ioManager.GetFilesWithExtension(EventScriptsSubdirectory, platformIdentifier.ScriptFileExtension, false, cancellationToken); - var resolvedScriptsDir = ioManager.ResolvePath(EventScriptsSubdirectory); - - var scriptFiles = files - .Select(x => ioManager.GetFileName(x)) - .Where(x => scriptNames.Any( - scriptName => x.StartsWith(scriptName, StringComparison.Ordinal))) - .ToList(); - - if (scriptFiles.Count == 0) - { - logger.LogTrace("No event scripts starting with \"{scriptName}\" detected", String.Join("\" or \"", scriptNames)); - return; - } - - foreach (var scriptFile in scriptFiles) - { - logger.LogTrace("Running event script {scriptFile}...", scriptFile); - await using (var script = processExecutor.LaunchProcess( - ioManager.ConcatPath(resolvedScriptsDir, scriptFile), - resolvedScriptsDir, - String.Join( - ' ', - parameters.Select(arg => - { - if (arg == null) - return "(NULL)"; - - if (!arg.Contains(' ', StringComparison.Ordinal)) - return arg; - - arg = arg.Replace("\"", "\\\"", StringComparison.Ordinal); - - return $"\"{arg}\""; - })), - readStandardHandles: true, - noShellExecute: true)) - using (cancellationToken.Register(() => script.Terminate())) - { - if (sessionConfiguration.LowPriorityDeploymentProcesses && deploymentPipeline) - script.AdjustPriority(false); - - var exitCode = await script.Lifetime; - cancellationToken.ThrowIfCancellationRequested(); - var scriptOutput = await script.GetCombinedOutput(cancellationToken); - if (exitCode != 0) - throw new JobException($"Script {scriptFile} exited with code {exitCode}:{Environment.NewLine}{scriptOutput}"); - else - logger.LogDebug("Script output:{newLine}{scriptOutput}", Environment.NewLine, scriptOutput); - } - } + logger.LogTrace("No event script for event {event}!", eventType); + return ValueTask.CompletedTask; } + + return ExecuteEventScripts(parameters, deploymentPipeline, cancellationToken, scriptNames); + } + + /// + public ValueTask? HandleCustomEvent(string scriptName, IEnumerable parameters, CancellationToken cancellationToken) + { + var scriptNameIsTgsEventName = EventTypeScriptFileNameMap + .Values + .SelectMany(scriptNames => scriptNames) + .Any(tgsScriptName => tgsScriptName.Equals( + scriptName, + platformIdentifier.IsWindows + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)); + if (scriptNameIsTgsEventName) + { + logger.LogWarning("DMAPI attempted to execute TGS reserved event: {eventName}", scriptName); + return null; + } + +#pragma warning disable CA2012 // Use ValueTasks correctly + return ExecuteEventScripts(parameters, false, cancellationToken, scriptName); +#pragma warning restore CA2012 // Use ValueTasks correctly } /// @@ -758,5 +727,74 @@ namespace Tgstation.Server.Host.Components.StaticFiles throw new InvalidOperationException("Attempted to access file outside of configuration manager!"); return resolved; } + + /// + /// Execute a set of given . + /// + /// An of parameters for the . + /// If this event is part of the deployment pipeline. + /// The for the operation. + /// The names of the scripts to execute. + /// A representing the running operation. + async ValueTask ExecuteEventScripts(IEnumerable parameters, bool deploymentPipeline, CancellationToken cancellationToken, params string[] scriptNames) + { + await EnsureDirectories(cancellationToken); + + // always execute in serial + using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken)) + { + var files = await ioManager.GetFilesWithExtension(EventScriptsSubdirectory, platformIdentifier.ScriptFileExtension, false, cancellationToken); + var resolvedScriptsDir = ioManager.ResolvePath(EventScriptsSubdirectory); + + var scriptFiles = files + .Select(x => ioManager.GetFileName(x)) + .Where(x => scriptNames.Any( + scriptName => x.StartsWith(scriptName, StringComparison.Ordinal))) + .ToList(); + + if (scriptFiles.Count == 0) + { + logger.LogTrace("No event scripts starting with \"{scriptName}\" detected", String.Join("\" or \"", scriptNames)); + return; + } + + foreach (var scriptFile in scriptFiles) + { + logger.LogTrace("Running event script {scriptFile}...", scriptFile); + await using (var script = processExecutor.LaunchProcess( + ioManager.ConcatPath(resolvedScriptsDir, scriptFile), + resolvedScriptsDir, + String.Join( + ' ', + parameters.Select(arg => + { + if (arg == null) + return "(NULL)"; + + if (!arg.Contains(' ', StringComparison.Ordinal)) + return arg; + + arg = arg.Replace("\"", "\\\"", StringComparison.Ordinal); + + return $"\"{arg}\""; + })), + readStandardHandles: true, + noShellExecute: true)) + using (cancellationToken.Register(() => script.Terminate())) + { + if (sessionConfiguration.LowPriorityDeploymentProcesses && deploymentPipeline) + script.AdjustPriority(false); + + var exitCode = await script.Lifetime; + cancellationToken.ThrowIfCancellationRequested(); + var scriptOutput = await script.GetCombinedOutput(cancellationToken); + if (exitCode != 0) + throw new JobException($"Script {scriptFile} exited with code {exitCode}:{Environment.NewLine}{scriptOutput}"); + else + logger.LogDebug("Script output:{newLine}{scriptOutput}", Environment.NewLine, scriptOutput); + } + } + } + } } } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index 8e891f10c3..211249e0df 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -507,6 +507,10 @@ namespace Tgstation.Server.Host.Components.Watchdog HandleChatResponses(result); } + /// + ValueTask? IEventConsumer.HandleCustomEvent(string eventName, IEnumerable parameters, CancellationToken cancellationToken) + => throw new NotSupportedException("Watchdogs do not support custom events!"); + /// /// Starts all s. /// diff --git a/tests/DMAPI/BasicOperation/Test.dm b/tests/DMAPI/BasicOperation/Test.dm index 022088fc67..aa0455b659 100644 --- a/tests/DMAPI/BasicOperation/Test.dm +++ b/tests/DMAPI/BasicOperation/Test.dm @@ -19,6 +19,21 @@ if(!("test" in world_params) || world_params["test"] != "bababooey") FailTest("Expected parameter test=bababooey but did not receive", "test_fail_reason.txt") + fdel("test_event_output.txt") + var/test_data = "nwfiuurhfu" + world.TgsTriggerEvent("test_event", list(test_data), TRUE) + if(!fexists("test_event_output.txt")) + FailTest("Expected test_event_output.txt to exist here", "test_fail_reason.txt") + + var/test_contents = copytext(file2text("test_event_output.txt"), 1, length(test_data) + 1) + if(test_contents != test_data) + FailTest("Expected test_event_output.txt to contain [test_data] here. Got [test_contents]", "test_fail_reason.txt") + + fdel("test_event_output.txt") + world.TgsTriggerEvent("test_event", list("asdf"), FALSE) + if(fexists("test_event_output.txt")) + FailTest("Expected test_event_output.txt to not exist here", "test_fail_reason.txt") + world.log << "sleep2" sleep(150) world.log << "Terminating..." diff --git a/tests/DMAPI/BasicOperation/test_event-qwer.bat b/tests/DMAPI/BasicOperation/test_event-qwer.bat new file mode 100644 index 0000000000..ecbce0d0af --- /dev/null +++ b/tests/DMAPI/BasicOperation/test_event-qwer.bat @@ -0,0 +1,7 @@ +echo "Running test_event script" + +rem mingw has their own /usr/bin/timeout +C:\Windows\system32\timeout.exe /t 5 +cd %1 +cd tests\DMAPI\BasicOperation +echo %2 > test_event_output.txt diff --git a/tests/DMAPI/BasicOperation/test_event-qwer.sh b/tests/DMAPI/BasicOperation/test_event-qwer.sh new file mode 100755 index 0000000000..185bc88fed --- /dev/null +++ b/tests/DMAPI/BasicOperation/test_event-qwer.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +echo "Running test_event script - $1 - $2" + +sleep 5 + +cd $1 +cd tests/DMAPI/BasicOperation + +echo $2 > test_event_output.txt + diff --git a/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs index 9ef929701b..eb39799414 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/ConfigurationTest.cs @@ -118,7 +118,7 @@ namespace Tgstation.Server.Tests.Live.Instance await using var memoryStream2 = new MemoryStream(Encoding.UTF8.GetBytes("bbb")); await configurationClient.Write(staticFile2, memoryStream2, cancellationToken); - async ValueTask UploadScript(string scriptId) + async ValueTask UploadScript(string scriptId, bool basic) { var shellScriptExtension = new PlatformIdentifier().IsWindows ? ".bat" : ".sh"; var scriptName = $"{scriptId}{shellScriptExtension}"; @@ -127,15 +127,16 @@ namespace Tgstation.Server.Tests.Live.Instance Path = $"/EventScripts/{scriptName}" }; - await using var readStream = ioManager.GetFileStream($"../../../../DMAPI/LongRunning/{scriptName}", false); + await using var readStream = ioManager.GetFileStream($"../../../../DMAPI/{(basic ? "BasicOperation" : "LongRunning")}/{scriptName}", false); await configurationClient.Write( resourcingScript, readStream, cancellationToken); } - await UploadScript("PreCompile-GenerateRandomResource"); - await UploadScript("EngineActiveVersionChange-SetupEnv"); + await UploadScript("PreCompile-GenerateRandomResource", false); + await UploadScript("EngineActiveVersionChange-SetupEnv", false); + await UploadScript("test_event-qwer", true); } return ValueTaskExtensions.WhenAll( diff --git a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs index fd927a684a..4a453d4f51 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs @@ -1485,7 +1485,7 @@ namespace Tgstation.Server.Tests.Live.Instance var newStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.IsTrue(newStatus.SoftShutdown.Value || newStatus.Status.Value == WatchdogStatus.Offline); - var timeout = 20; + var timeout = 40; do { await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); diff --git a/tgstation-server.sln b/tgstation-server.sln index fead8a204d..ad10c6fa1b 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -176,6 +176,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BasicOperation", "BasicOper tests\DMAPI\BasicOperation\basic operation_test.dme = tests\DMAPI\BasicOperation\basic operation_test.dme tests\DMAPI\BasicOperation\Config.dm = tests\DMAPI\BasicOperation\Config.dm tests\DMAPI\BasicOperation\Test.dm = tests\DMAPI\BasicOperation\Test.dm + tests\DMAPI\BasicOperation\test_event-qwer.sh = tests\DMAPI\BasicOperation\test_event-qwer.sh + tests\DMAPI\BasicOperation\test_event-qwer.bat = tests\DMAPI\BasicOperation\test_event-qwer.bat EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildFail", "BuildFail", "{103C61AB-67D6-46FE-AA47-CC633B88EE0F}"