tgstation-server 6.12.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
SessionController.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.Globalization;
4using System.Linq;
5using System.Text;
6using System.Threading;
7using System.Threading.Tasks;
8
9using Microsoft.Extensions.Logging;
10using Microsoft.Extensions.Logging.Abstractions;
11
12using Newtonsoft.Json;
13
14using Serilog.Context;
15
30
32{
35 {
39 internal static bool LogTopicRequests { get; set; } = true;
40
43
46 {
47 get
48 {
49 if (!Lifetime.IsCompleted)
50 throw new InvalidOperationException("ApiValidated cannot be checked while Lifetime is incomplete!");
52 }
53 }
54
57
60
63
65 public Version? DMApiVersion { get; private set; }
66
68 public bool TerminationWasIntentional => terminationWasIntentional || (Lifetime.IsCompleted && Lifetime.Result == 0);
69
71 public Task<LaunchResult> LaunchResult { get; }
72
74 public Task<int?> Lifetime { get; }
75
77 public Task OnStartup => startupTcs.Task;
78
80 public Task OnReboot => rebootTcs.Task;
81
83 public Task RebootGate
84 {
85 get => rebootGate;
86 set
87 {
88 var tcs = new TaskCompletionSource<Task>();
89 async Task Wrap()
90 {
91 var toAwait = await tcs.Task;
92 await toAwait;
93 await value;
94 }
95
96 tcs.SetResult(Interlocked.Exchange(ref rebootGate, Wrap()));
97 }
98 }
99
101 public Task OnPrime => primeTcs.Task;
102
105
108
110 public string DumpFileExtension => engineLock.UseDotnetDump
111 ? ".net.dmp"
112 : ".dmp";
113
118
123
126
128 public DateTimeOffset? LaunchTime => process.LaunchTime;
129
133 readonly Byond.TopicSender.ITopicClient byondTopicSender;
134
139
144
149
154
159
164
169
174
178 readonly TaskCompletionSource initialBridgeRequestTcs;
179
184
188 readonly CancellationTokenSource sessionDurationCts;
189
193 readonly object synchronizationLock;
194
198 readonly bool apiValidationSession;
199
203 volatile TaskCompletionSource startupTcs;
204
208 volatile TaskCompletionSource rebootTcs;
209
213 volatile TaskCompletionSource primeTcs;
214
218 volatile Task rebootGate;
219
224
229
234
239
244
249
254
276 ReattachInformation reattachInformation,
277 Api.Models.Instance metadata,
280 Byond.TopicSender.ITopicClient byondTopicSender,
282 IBridgeRegistrar bridgeRegistrar,
284 IAssemblyInformationProvider assemblyInformationProvider,
288 ILogger<SessionController> logger,
289 Func<ValueTask> postLifetimeCallback,
290 uint? startupTimeout,
291 bool reattached,
292 bool apiValidate)
293 : base(logger)
294 {
295 ReattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
296 this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
297 this.process = process ?? throw new ArgumentNullException(nameof(process));
298 this.engineLock = engineLock ?? throw new ArgumentNullException(nameof(engineLock));
299 this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
300 this.chatTrackingContext = chatTrackingContext ?? throw new ArgumentNullException(nameof(chatTrackingContext));
301 ArgumentNullException.ThrowIfNull(bridgeRegistrar);
302
303 this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
304 ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
305
306 this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
307 this.dotnetDumpService = dotnetDumpService ?? throw new ArgumentNullException(nameof(dotnetDumpService));
308 this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
309
310 apiValidationSession = apiValidate;
311
312 disposed = false;
314 released = false;
315
316 startupTcs = new TaskCompletionSource();
317 rebootTcs = new TaskCompletionSource();
318 primeTcs = new TaskCompletionSource();
319
320 rebootGate = Task.CompletedTask;
321 customEventProcessingTask = Task.CompletedTask;
322
323 // Run this asynchronously because we want to try to avoid any effects sending topics to the server while the initial bridge request is processing
324 // It MAY be the source of a DD crash. See this gist https://gist.github.com/Cyberboss/7776bbeff3a957d76affe0eae95c9f14
325 // Worth further investigation as to if that sequence of events is a reliable crash vector and opening a BYOND bug if it is
326 initialBridgeRequestTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
327 sessionDurationCts = new CancellationTokenSource();
328
330 synchronizationLock = new object();
331
333 {
334 bridgeRegistration = bridgeRegistrar.RegisterHandler(this);
335 this.chatTrackingContext.SetChannelSink(this);
336 }
337 else
338 logger.LogTrace(
339 "Not registering session with {reasonWhyDmApiIsBad} DMAPI version for interop!",
340 reattachInformation.Dmb.CompileJob.DMApiVersion == null
341 ? "no"
342 : $"incompatible ({reattachInformation.Dmb.CompileJob.DMApiVersion})");
343
344 async Task<int?> WrapLifetime()
345 {
346 var exitCode = await process.Lifetime;
347 await postLifetimeCallback();
348 if (postValidationShutdownTask != null)
350
351 return exitCode;
352 }
353
354 Lifetime = WrapLifetime();
355
357 assemblyInformationProvider,
359 startupTimeout,
360 reattached,
361 apiValidate);
362
363 logger.LogDebug(
364 "Created session controller. CommsKey: {accessIdentifier}, Port: {port}",
365 reattachInformation.AccessIdentifier,
366 reattachInformation.Port);
367 }
368
370 public async ValueTask DisposeAsync()
371 {
373 {
374 if (disposed)
375 return;
376 disposed = true;
377 }
378
379 Logger.LogTrace("Disposing...");
380
381 sessionDurationCts.Cancel();
382 var cancellationToken = CancellationToken.None; // DCT: None available
383 var semaphoreLockTask = TopicSendSemaphore.Lock(cancellationToken);
384
385 if (!released)
386 {
388 Logger,
389 process,
392 cancellationToken);
393 }
394
395 await process.DisposeAsync();
396 engineLock.Dispose();
397 bridgeRegistration?.Dispose();
398 var regularDmbDisposeTask = ReattachInformation.Dmb.DisposeAsync();
399 var initialDmb = ReattachInformation.InitialDmb;
400 if (initialDmb != null)
401 await initialDmb.DisposeAsync();
402
403 await regularDmbDisposeTask;
404
405 chatTrackingContext.Dispose();
406 sessionDurationCts.Dispose();
407
408 if (!released)
409 await Lifetime; // finish the async callback
410
411 (await semaphoreLockTask).Dispose();
413
415 }
416
418 public async ValueTask<BridgeResponse?> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
419 {
420 ArgumentNullException.ThrowIfNull(parameters);
421
422 using (LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, metadata.Id))
423 {
424 Logger.LogTrace("Handling bridge request...");
425
426 try
427 {
428 return await ProcessBridgeCommand(parameters, cancellationToken);
429 }
430 finally
431 {
432 initialBridgeRequestTcs.TrySetResult();
433 }
434 }
435 }
436
438 public ValueTask Release()
439 {
441
445 released = true;
446 return DisposeAsync();
447 }
448
450 public ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
451 => SendCommand(parameters, false, cancellationToken);
452
454 public async ValueTask<bool> SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
455 {
456 if (RebootState == newRebootState)
457 return true;
458
459 Logger.LogTrace("Changing reboot state to {newRebootState}", newRebootState);
460
461 ReattachInformation.RebootState = newRebootState;
462 var result = await SendCommand(
463 new TopicParameters(newRebootState),
464 cancellationToken);
465
466 return result != null && result.ErrorMessage == null;
467 }
468
470 public void ResetRebootState()
471 {
473 Logger.LogTrace("Resetting reboot state...");
474 ReattachInformation.RebootState = RebootState.Normal;
475 }
476
478 public void AdjustPriority(bool higher) => process.AdjustPriority(higher);
479
482
485
488 {
489 var oldDmb = ReattachInformation.Dmb;
490 ReattachInformation.Dmb = dmbProvider ?? throw new ArgumentNullException(nameof(dmbProvider));
491 return oldDmb;
492 }
493
495 public async ValueTask InstanceRenamed(string newInstanceName, CancellationToken cancellationToken)
496 {
497 var runtimeInformation = ReattachInformation.RuntimeInformation;
498 if (runtimeInformation != null)
499 runtimeInformation.InstanceName = newInstanceName;
500
501 await SendCommand(
503 cancellationToken);
504 }
505
507 public async ValueTask UpdateChannels(IEnumerable<ChannelRepresentation> newChannels, CancellationToken cancellationToken)
508 => await SendCommand(
509 new TopicParameters(
510 new ChatUpdate(newChannels)),
511 cancellationToken);
512
514 public ValueTask CreateDump(string outputFile, bool minidump, CancellationToken cancellationToken)
515 {
517 return dotnetDumpService.Dump(process, outputFile, minidump, cancellationToken);
518
519 return process.CreateDump(outputFile, minidump, cancellationToken);
520 }
521
531 async Task<LaunchResult> GetLaunchResult(
532 IAssemblyInformationProvider assemblyInformationProvider,
534 uint? startupTimeout,
535 bool reattached,
536 bool apiValidate)
537 {
538 var startTime = DateTimeOffset.UtcNow;
539 var useBridgeRequestForLaunchResult = !reattached && (apiValidate || DMApiAvailable);
540 var startupTask = useBridgeRequestForLaunchResult
541 ? initialBridgeRequestTcs.Task
543 var toAwait = Task.WhenAny(startupTask, process.Lifetime);
544
545 if (startupTimeout.HasValue)
546 toAwait = Task.WhenAny(
547 toAwait,
549 TimeSpan.FromSeconds(startupTimeout.Value),
550 CancellationToken.None)
551 .AsTask()); // DCT: None available, task will clean up after delay
552
553 Logger.LogTrace(
554 "Waiting for LaunchResult based on {launchResultCompletionCause}{possibleTimeout}...",
555 useBridgeRequestForLaunchResult ? "initial bridge request" : "process startup",
556 startupTimeout.HasValue ? $" with a timeout of {startupTimeout.Value}s" : String.Empty);
557
558 await toAwait;
559
560 var result = new LaunchResult
561 {
562 ExitCode = process.Lifetime.IsCompleted ? await process.Lifetime : null,
563 StartupTime = startupTask.IsCompleted ? (DateTimeOffset.UtcNow - startTime) : null,
564 };
565
566 Logger.LogTrace("Launch result: {launchResult}", result);
567
568 if (!result.ExitCode.HasValue && reattached && !disposed)
569 {
570 var reattachResponse = await SendCommand(
571 new TopicParameters(
572 assemblyInformationProvider.Version,
574 true,
575 sessionDurationCts.Token);
576
577 if (reattachResponse != null)
578 {
579 if (reattachResponse?.CustomCommands != null)
580 chatTrackingContext.CustomCommands = reattachResponse.CustomCommands;
581 else if (reattachResponse != null)
582 Logger.Log(
583 CompileJob.DMApiVersion >= new Version(5, 2, 0)
584 ? LogLevel.Warning
585 : LogLevel.Debug,
586 "DMAPI Interop v{interopVersion} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
587 CompileJob.DMApiVersion!.Semver());
588 }
589 }
590
591 return result;
592 }
593
597 void CheckDisposed() => ObjectDisposedException.ThrowIf(disposed, this);
598
604 async Task PostValidationShutdown(Task<bool> proceedTask)
605 {
606 Logger.LogTrace("Entered post validation terminate task.");
607 if (!await proceedTask)
608 {
609 Logger.LogTrace("Not running post validation terminate task for repeated bridge request.");
610 return;
611 }
612
613 const int GracePeriodSeconds = 30;
614 Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds);
615 var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None).AsTask(); // DCT: None available
616 await Task.WhenAny(process.Lifetime, delayTask);
617
618 if (!process.Lifetime.IsCompleted)
619 {
620 Logger.LogWarning("DMAPI took too long to shutdown server after validation request!");
622 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
623 }
624 else
625 Logger.LogTrace("Server exited properly post validation.");
626 }
627
634#pragma warning disable CA1502 // TODO: Decomplexify
635 async ValueTask<BridgeResponse?> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
636 {
637 var response = new BridgeResponse();
638 switch (parameters.CommandType)
639 {
640 case BridgeCommandType.ChatSend:
641 if (parameters.ChatMessage == null)
642 return BridgeError("Missing chatMessage field!");
643
644 if (parameters.ChatMessage.ChannelIds == null)
645 return BridgeError("Missing channelIds field in chatMessage!");
646
647 if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
648 return BridgeError("Invalid channelIds in chatMessage!");
649
650 if (parameters.ChatMessage.Text == null)
651 return BridgeError("Missing message field in chatMessage!");
652
653 var anyFailed = false;
654 var parsedChannels = parameters.ChatMessage.ChannelIds.Select(
655 channelString =>
656 {
657 anyFailed |= !UInt64.TryParse(channelString, out var channelId);
658 return channelId;
659 });
660
661 if (anyFailed)
662 return BridgeError("Failed to parse channelIds as U64!");
663
665 parameters.ChatMessage,
666 parsedChannels);
667 break;
668 case BridgeCommandType.Prime:
669 Interlocked.Exchange(ref primeTcs, new TaskCompletionSource()).SetResult();
670 break;
671 case BridgeCommandType.Kill:
672 Logger.LogInformation("Bridge requested process termination!");
673 chatTrackingContext.Active = false;
676 break;
677 case BridgeCommandType.DeprecatedPortUpdate:
678 return BridgeError("Port switching is no longer supported!");
679 case BridgeCommandType.Startup:
680 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
681
683 {
684 var proceedTcs = new TaskCompletionSource<bool>();
685 var firstValidationRequest = Interlocked.CompareExchange(ref postValidationShutdownTask, PostValidationShutdown(proceedTcs.Task), null) == null;
686 proceedTcs.SetResult(firstValidationRequest);
687
688 if (!firstValidationRequest)
689 return BridgeError("Startup bridge request was repeated!");
690 }
691
692 if (parameters.Version == null)
693 return BridgeError("Missing dmApiVersion field!");
694
695 DMApiVersion = parameters.Version;
696
697 // TODO: When OD figures out how to unite port and topic_port, set an upper version bound on OD for this check
699 || (EngineVersion.Engine == EngineType.OpenDream && DMApiVersion < new Version(5, 7)))
700 {
702 return BridgeError("Incompatible dmApiVersion!");
703 }
704
705 switch (parameters.MinimumSecurityLevel)
706 {
707 case DreamDaemonSecurity.Ultrasafe:
708 apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
709 break;
710 case DreamDaemonSecurity.Safe:
712 break;
713 case DreamDaemonSecurity.Trusted:
715 break;
716 case null:
717 return BridgeError("Missing minimumSecurityLevel field!");
718 default:
719 return BridgeError("Invalid minimumSecurityLevel!");
720 }
721
722 Logger.LogTrace("ApiValidationStatus set to {apiValidationStatus}", apiValidationStatus);
723
724 // we create new runtime info here because of potential .Dmb changes (i think. i forget...)
725 response.RuntimeInformation = new RuntimeInformation(
734
735 if (parameters.TopicPort.HasValue)
736 {
737 var newTopicPort = parameters.TopicPort.Value;
738 Logger.LogInformation("Server is requesting use of port {topicPort} for topic communications", newTopicPort);
739 ReattachInformation.TopicPort = newTopicPort;
740 }
741
742 // Load custom commands
743 chatTrackingContext.CustomCommands = parameters.CustomCommands ?? Array.Empty<CustomCommand>();
744 chatTrackingContext.Active = true;
745 Interlocked.Exchange(ref startupTcs, new TaskCompletionSource()).SetResult();
746 break;
747 case BridgeCommandType.Reboot:
748 Interlocked.Increment(ref rebootBridgeRequestsProcessing);
749 try
750 {
751 chatTrackingContext.Active = false;
752 Interlocked.Exchange(ref rebootTcs, new TaskCompletionSource()).SetResult();
753 await RebootGate.WaitAsync(cancellationToken);
754 }
755 finally
756 {
757 Interlocked.Decrement(ref rebootBridgeRequestsProcessing);
758 }
759
760 break;
761 case BridgeCommandType.Chunk:
762 return await ProcessChunk<BridgeParameters, BridgeResponse>(ProcessBridgeCommand, BridgeError, parameters.Chunk, cancellationToken);
763 case BridgeCommandType.Event:
764 return TriggerCustomEvent(parameters.EventInvocation);
765 case null:
766 return BridgeError("Missing commandType!");
767 default:
768 return BridgeError($"commandType {parameters.CommandType} not supported!");
769 }
770
771 return response;
772 }
773#pragma warning restore CA1502
774
781 {
782 Logger.LogWarning("Bridge request error: {message}", message);
783 return new BridgeResponse
784 {
785 ErrorMessage = message,
786 };
787 }
788
795 async ValueTask<CombinedTopicResponse?> SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
796 {
797 parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
798
799 var fullCommandString = GenerateQueryString(parameters, out var json);
800 if (LogTopicRequests)
801 Logger.LogTrace("Topic request: {json}", json);
802 var fullCommandByteCount = Encoding.UTF8.GetByteCount(fullCommandString);
803 var topicPriority = parameters.IsPriority;
804 if (fullCommandByteCount <= DMApiConstants.MaximumTopicRequestLength)
805 return await SendRawTopic(fullCommandString, topicPriority, cancellationToken);
806
807 var interopChunkingVersion = new Version(5, 6, 0);
808 if (ReattachInformation.Dmb.CompileJob.DMApiVersion < interopChunkingVersion)
809 {
810 Logger.LogWarning(
811 "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}!",
813 fullCommandByteCount,
814 interopChunkingVersion);
815 return null;
816 }
817
818 var payloadId = NextPayloadId;
819
820 // AccessIdentifer is just noise in a chunked request
821 parameters.AccessIdentifier = null!;
822 GenerateQueryString(parameters, out json);
823
824 // yes, this straight up ignores unicode, precalculating it is useless when we don't
825 // even know if the UTF8 bytes of the url encoded chunk will fit the window until we do said encoding
826 var fullPayloadSize = (uint)json.Length;
827
828 List<string>? chunkQueryStrings = null;
829 for (var chunkCount = 2; chunkQueryStrings == null; ++chunkCount)
830 {
831 var standardChunkSize = fullPayloadSize / chunkCount;
832 var bigChunkSize = standardChunkSize + (fullPayloadSize % chunkCount);
833 if (bigChunkSize > DMApiConstants.MaximumTopicRequestLength)
834 continue;
835
836 chunkQueryStrings = new List<string>();
837 for (var i = 0U; i < chunkCount; ++i)
838 {
839 var startIndex = i * standardChunkSize;
840 var subStringLength = Math.Min(
841 fullPayloadSize - startIndex,
842 i == chunkCount - 1
843 ? bigChunkSize
844 : standardChunkSize);
845 var chunkPayload = json.Substring((int)startIndex, (int)subStringLength);
846
847 var chunk = new ChunkData
848 {
849 Payload = chunkPayload,
850 PayloadId = payloadId,
851 SequenceId = i,
852 TotalChunks = (uint)chunkCount,
853 };
854
855 var chunkParameters = new TopicParameters(chunk)
856 {
857 AccessIdentifier = ReattachInformation.AccessIdentifier,
858 };
859
860 var chunkCommandString = GenerateQueryString(chunkParameters, out _);
861 if (Encoding.UTF8.GetByteCount(chunkCommandString) > DMApiConstants.MaximumTopicRequestLength)
862 {
863 // too long when encoded, need more chunks
864 chunkQueryStrings = null;
865 break;
866 }
867
868 chunkQueryStrings.Add(chunkCommandString);
869 }
870 }
871
872 Logger.LogTrace("Chunking topic request ({totalChunks} total)...", chunkQueryStrings.Count);
873
874 CombinedTopicResponse? combinedResponse = null;
875 bool LogRequestIssue(bool possiblyFromCompletedRequest)
876 {
877 if (combinedResponse?.InteropResponse == null || combinedResponse.InteropResponse.ErrorMessage != null)
878 {
879 Logger.LogWarning(
880 "Topic request {chunkingStatus} failed!{potentialRequestError}",
881 possiblyFromCompletedRequest ? "final chunk" : "chunking",
882 combinedResponse?.InteropResponse?.ErrorMessage != null
883 ? $" Request error: {combinedResponse.InteropResponse.ErrorMessage}"
884 : String.Empty);
885 return true;
886 }
887
888 return false;
889 }
890
891 foreach (var chunkCommandString in chunkQueryStrings)
892 {
893 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
894 if (LogRequestIssue(chunkCommandString == chunkQueryStrings.Last()))
895 return null;
896 }
897
898 while ((combinedResponse?.InteropResponse?.MissingChunks?.Count ?? 0) > 0)
899 {
900 Logger.LogWarning("DD is still missing some chunks of topic request P{payloadId}! Sending missing chunks...", payloadId);
901 var missingChunks = combinedResponse!.InteropResponse!.MissingChunks!;
902 var lastIndex = missingChunks.Last();
903 foreach (var missingChunkIndex in missingChunks)
904 {
905 var chunkCommandString = chunkQueryStrings[(int)missingChunkIndex];
906 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
907 if (LogRequestIssue(missingChunkIndex == lastIndex))
908 return null;
909 }
910 }
911
912 return combinedResponse;
913 }
914
921 string GenerateQueryString(TopicParameters parameters, out string json)
922 {
923 json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
924 var commandString = String.Format(
925 CultureInfo.InvariantCulture,
926 "?{0}={1}",
928 byondTopicSender.SanitizeString(json));
929 return commandString;
930 }
931
939 async ValueTask<CombinedTopicResponse?> SendRawTopic(string queryString, bool priority, CancellationToken cancellationToken)
940 {
941 if (disposed)
942 {
943 Logger.LogWarning(
944 "Attempted to send a topic on a disposed SessionController");
945 return null;
946 }
947
948 var targetPort = ReattachInformation.TopicPort ?? ReattachInformation.Port;
949 Byond.TopicSender.TopicResponse? byondResponse;
950 using (await TopicSendSemaphore.Lock(cancellationToken))
951 byondResponse = await byondTopicSender.SendWithOptionalPriority(
953 LogTopicRequests
954 ? Logger
955 : NullLogger.Instance,
956 queryString,
957 targetPort,
958 priority,
959 cancellationToken);
960
961 if (byondResponse == null)
962 {
963 if (priority)
964 Logger.LogError(
965 "Unable to send priority topic \"{queryString}\"!",
966 queryString);
967
968 return null;
969 }
970
971 var topicReturn = byondResponse.StringData;
972
973 TopicResponse? interopResponse = null;
974 if (topicReturn != null)
975 try
976 {
977 interopResponse = JsonConvert.DeserializeObject<TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
978 }
979 catch (Exception ex)
980 {
981 Logger.LogWarning(ex, "Invalid interop response: {topicReturnString}", topicReturn);
982 }
983
984 return new CombinedTopicResponse(byondResponse, interopResponse);
985 }
986
994 async ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, bool bypassLaunchResult, CancellationToken cancellationToken)
995 {
996 ArgumentNullException.ThrowIfNull(parameters);
997
998 if (Lifetime.IsCompleted || disposed)
999 {
1000 Logger.LogWarning(
1001 "Attempted to send a command to an inactive SessionController: {commandType}",
1002 parameters.CommandType);
1003 return null;
1004 }
1005
1006 if (!DMApiAvailable)
1007 {
1008 Logger.LogTrace("Not sending topic request {commandType} to server without/with incompatible DMAPI!", parameters.CommandType);
1009 return null;
1010 }
1011
1012 var reboot = OnReboot;
1013 if (!bypassLaunchResult)
1014 {
1015 var launchResult = await LaunchResult.WaitAsync(cancellationToken);
1016 if (launchResult.ExitCode.HasValue)
1017 {
1018 Logger.LogDebug("Not sending topic request {commandType} to server that failed to launch!", parameters.CommandType);
1019 return null;
1020 }
1021 }
1022
1023 // meh, this is kind of a hack, but it works
1025 {
1026 Logger.LogDebug("Not sending topic request {commandType} to server that is rebooting/starting.", parameters.CommandType);
1027 return null;
1028 }
1029
1030 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1031 var combinedCancellationToken = cts.Token;
1032 async ValueTask CancelIfLifetimeElapses()
1033 {
1034 try
1035 {
1036 var completed = await Task.WhenAny(Lifetime, reboot).WaitAsync(combinedCancellationToken);
1037
1038 Logger.LogDebug(
1039 "Server {action}, cancelling pending command: {commandType}",
1040 completed != reboot
1041 ? "process ended"
1042 : "rebooting",
1043 parameters.CommandType);
1044 cts.Cancel();
1045 }
1046 catch (OperationCanceledException)
1047 {
1048 // expected, not even worth tracing
1049 }
1050 catch (Exception ex)
1051 {
1052 Logger.LogError(ex, "Error in CancelIfLifetimeElapses!");
1053 }
1054 }
1055
1056 TopicResponse? fullResponse = null;
1057 var lifetimeWatchingTask = CancelIfLifetimeElapses();
1058 try
1059 {
1060 var combinedResponse = await SendTopicRequest(parameters, combinedCancellationToken);
1061
1062 void LogCombinedResponse()
1063 {
1064 if (LogTopicRequests && combinedResponse != null)
1065 Logger.LogTrace("Topic response: {topicString}", combinedResponse.ByondTopicResponse.StringData ?? "(NO STRING DATA)");
1066 }
1067
1068 LogCombinedResponse();
1069
1070 if (combinedResponse?.InteropResponse?.Chunk != null)
1071 {
1072 Logger.LogTrace("Topic response is chunked...");
1073
1074 ChunkData? nextChunk = combinedResponse.InteropResponse.Chunk;
1075 do
1076 {
1077 var nextRequest = await ProcessChunk<TopicResponse, ChunkedTopicParameters>(
1078 (completedResponse, _) =>
1079 {
1080 fullResponse = completedResponse;
1081 return ValueTask.FromResult<ChunkedTopicParameters?>(null);
1082 },
1083 error =>
1084 {
1085 Logger.LogWarning("Topic response chunking error: {message}", error);
1086 return null;
1087 },
1088 combinedResponse?.InteropResponse?.Chunk,
1089 combinedCancellationToken);
1090
1091 if (nextRequest != null)
1092 {
1093 nextRequest.PayloadId = nextChunk.PayloadId;
1094 combinedResponse = await SendTopicRequest(nextRequest, combinedCancellationToken);
1095 LogCombinedResponse();
1096 nextChunk = combinedResponse?.InteropResponse?.Chunk;
1097 }
1098 else
1099 nextChunk = null;
1100 }
1101 while (nextChunk != null);
1102 }
1103 else
1104 fullResponse = combinedResponse?.InteropResponse;
1105 }
1106 catch (OperationCanceledException ex)
1107 {
1108 Logger.LogDebug(
1109 ex,
1110 "Topic request {cancellationType}!",
1111 combinedCancellationToken.IsCancellationRequested
1112 ? cancellationToken.IsCancellationRequested
1113 ? "cancelled"
1114 : "aborted"
1115 : "timed out");
1116
1117 // throw only if the original token was the trigger
1118 cancellationToken.ThrowIfCancellationRequested();
1119 }
1120 finally
1121 {
1122 cts.Cancel();
1123 await lifetimeWatchingTask;
1124 }
1125
1126 if (fullResponse?.ErrorMessage != null)
1127 Logger.LogWarning(
1128 "Errored topic response for command {commandType}: {errorMessage}",
1129 parameters.CommandType,
1130 fullResponse.ErrorMessage);
1131
1132 return fullResponse;
1133 }
1134
1141 {
1142 if (invocation == null)
1143 return BridgeError("Missing eventInvocation!");
1144
1145 var eventName = invocation.EventName;
1146 if (eventName == null)
1147 return BridgeError("Missing eventName!");
1148
1149 var notifyCompletion = invocation.NotifyCompletion;
1150 if (!notifyCompletion.HasValue)
1151 return BridgeError("Missing notifyCompletion!");
1152
1153 var eventParams = new List<string>
1154 {
1156 };
1157
1158 eventParams.AddRange(invocation
1159 .Parameters?
1160 .Where(param => param != null)
1161 .Cast<string>()
1162 ?? Enumerable.Empty<string>());
1163
1164 var eventId = Guid.NewGuid();
1165 Logger.LogInformation("Triggering custom event \"{eventName}\": {eventId}", eventName, eventId);
1166
1167 var cancellationToken = sessionDurationCts.Token;
1168 ValueTask? eventTask = eventConsumer.HandleCustomEvent(eventName, eventParams, cancellationToken);
1169
1170 async Task ProcessEvent()
1171 {
1172 try
1173 {
1174 await eventTask.Value;
1175
1176 if (notifyCompletion.Value)
1177 await SendCommand(
1178 new TopicParameters(eventId),
1179 cancellationToken);
1180 else
1181 Logger.LogTrace("Finished custom event {eventId}, not sending notification.", eventId);
1182 }
1183 catch (OperationCanceledException ex)
1184 {
1185 Logger.LogDebug(ex, "Custom event invocation {eventId} aborted!", eventId);
1186 }
1187 catch (Exception ex)
1188 {
1189 Logger.LogWarning(ex, "Custom event invocation {eventId} errored!", eventId);
1190 }
1191 }
1192
1193 if (!eventTask.HasValue)
1194 return BridgeError("Event refused to execute due to matching a TGS event!");
1195
1196 lock (sessionDurationCts)
1197 {
1198 var previousEventProcessingTask = customEventProcessingTask;
1199 var eventProcessingTask = ProcessEvent();
1200 customEventProcessingTask = Task.WhenAll(customEventProcessingTask, eventProcessingTask);
1201 }
1202
1203 return new BridgeResponse
1204 {
1205 EventId = notifyCompletion.Value
1206 ? eventId.ToString()
1207 : null,
1208 };
1209 }
1210 }
1211}
Information about an engine installation.
Metadata about a server instance.
Definition Instance.cs:9
virtual ? Version DMApiVersion
The DMAPI Version.
Definition CompileJob.cs:41
ChatMessage? ChatMessage
The Interop.ChatMessage for BridgeCommandType.ChatSend requests.
CustomEventInvocation? EventInvocation
The Bridge.CustomEventInvocation being triggered.
ushort? TopicPort
The port that should be used to send world topics, if not the default.
DreamDaemonSecurity? MinimumSecurityLevel
The minimum required DreamDaemonSecurity level for BridgeCommandType.Startup requests.
ChunkData? Chunk
The ChunkData for BridgeCommandType.Chunk requests.
Version? Version
The DMAPI global::System.Version for BridgeCommandType.Startup requests.
bool? NotifyCompletion
If the DMAPI should be notified when the event compeletes.
Representation of the initial data passed as part of a BridgeCommandType.Startup request.
Version ServerVersion
The IAssemblyInformationProvider.Version.
DreamDaemonVisibility Visibility
The DreamDaemonSecurity level of the launch.
string InstanceName
The NamedEntity.Name of the owner at the time of launch.
bool ApiValidateOnly
If DD should just respond if it's API is working and then exit.
DreamDaemonSecurity SecurityLevel
The DreamDaemonSecurity level of the launch.
ICollection< string >? ChannelIds
The ICollection<T> of Chat.ChannelRepresentation.Ids to sent the MessageContent to....
Represents an update of ChannelRepresentations.
Definition ChatUpdate.cs:13
A packet of a split serialized set of data.
Definition ChunkData.cs:7
uint? PayloadId
The ID of the full request to differentiate different chunkings.Nullable to prevent default value omi...
Class that deserializes chunked interop payloads.
Definition Chunker.cs:16
ILogger< Chunker > Logger
The ILogger for the Chunker.
Definition Chunker.cs:20
uint NextPayloadId
Gets a payload ID for use in a new ChunkSetInfo.
Definition Chunker.cs:26
Constants used for communication with the DMAPI.
static readonly JsonSerializerSettings SerializerSettings
JsonSerializerSettings for use when communicating with the DMAPI.
const uint MaximumTopicRequestLength
The maximum length in bytes of a Byond.TopicSender.ITopicClient payload.
static readonly Version InteropVersion
The DMAPI InteropVersion being used.
const string TopicData
Parameter json is encoded in for topic requests.
string AccessIdentifier
Used to identify and authenticate the DreamDaemon instance.
string? ErrorMessage
Any errors in the client's parameters.
static TopicParameters CreateInstanceRenamedTopicParameters(string newInstanceName)
Initializes a new instance of the TopicParameters class.
ChunkData? Chunk
The ChunkData for a partial request.
bool IsPriority
Whether or not the TopicParameters constitute a priority request.
Combines a Byond.TopicSender.TopicResponse with a TopicResponse.
TopicResponse? InteropResponse
The interop TopicResponse, if any.
Represents the result of trying to start a DD process.
Parameters necessary for duplicating a ISessionController session.
RuntimeInformation? RuntimeInformation
The Interop.Bridge.RuntimeInformation for the DMAPI.
IDmbProvider? InitialDmb
The IDmbProvider initially used to launch DreamDaemon. Should be a different IDmbProvider than Dmb....
IDmbProvider Dmb
The IDmbProvider used by DreamDaemon.
async ValueTask InstanceRenamed(string newInstanceName, CancellationToken cancellationToken)
Called when the owning Instance is renamed.A ValueTask representing the running operation.
ValueTask< TopicResponse?> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
Sends a command to DreamDaemon through /world/Topic().A ValueTask<TResult> resulting in the TopicResp...
async ValueTask< BridgeResponse?> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
Handle a set of bridge parameters .
readonly Byond.TopicSender.ITopicClient byondTopicSender
The Byond.TopicSender.ITopicClient for the SessionController.
string DumpFileExtension
The file extension to use for process dumps created from this session.
ApiValidationStatus apiValidationStatus
The ApiValidationStatus for the SessionController.
readonly object synchronizationLock
lock object for port updates and disposed.
DateTimeOffset? LaunchTime
When the process was started.
void AdjustPriority(bool higher)
Set's the owned global::System.Diagnostics.Process.PriorityClass to a non-normal value.
async ValueTask< TopicResponse?> SendCommand(TopicParameters parameters, bool bypassLaunchResult, CancellationToken cancellationToken)
Sends a command to DreamDaemon through /world/Topic().
volatile uint rebootBridgeRequestsProcessing
The number of currently active calls to ProcessBridgeRequest(BridgeParameters, CancellationToken) fro...
readonly IDotnetDumpService dotnetDumpService
The IDotnetDumpService for the SessionController.
readonly Api.Models.Instance metadata
The Instance metadata.
Task OnReboot
A Task that completes when the server calls /world/TgsReboot().
bool DMApiAvailable
If the DMAPI may be used this session.
readonly TaskCompletionSource initialBridgeRequestTcs
The TaskCompletionSource that completes when DD makes it's first bridge request.
bool terminationWasIntentional
Backing field for overriding TerminationWasIntentional.
bool disposed
If the SessionController has been disposed.
void ResetRebootState()
Changes RebootState to RebootState.Normal without telling the DMAPI.
readonly IEngineExecutableLock engineLock
The IEngineExecutableLock for the SessionController.
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the SessionController.
async ValueTask< BridgeResponse?> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
Handle a set of bridge parameters .A ValueTask<TResult> resulting in the BridgeResponse for the reque...
ReattachInformation ReattachInformation
The up to date Session.ReattachInformation.
volatile Task rebootGate
Backing field for RebootGate.
bool TerminationWasIntentional
If the DreamDaemon instance sent a.
readonly IEventConsumer eventConsumer
The IEventConsumer for the SessionController.
BridgeResponse BridgeError(string message)
Log and return a BridgeResponse for a given message .
bool released
If process should be kept alive instead.
readonly IChatManager chat
The IChatManager for the SessionController.
readonly IChatTrackingContext chatTrackingContext
The IChatTrackingContext for the SessionController.
Task< int?> Lifetime
The Task<TResult> resulting in the exit code of the process or null if the process was detached.
readonly CancellationTokenSource sessionDurationCts
A CancellationTokenSource used for tasks that should not exceed the lifetime of the session.
ValueTask CreateDump(string outputFile, bool minidump, CancellationToken cancellationToken)
Create a dump file of the process.A ValueTask representing the running operation.
volatile TaskCompletionSource startupTcs
The TaskCompletionSource that completes when DD sends a valid startup bridge request.
async Task PostValidationShutdown(Task< bool > proceedTask)
Terminates the server after ten seconds if it does not exit.
SessionController(ReattachInformation reattachInformation, Api.Models.Instance metadata, IProcess process, IEngineExecutableLock engineLock, Byond.TopicSender.ITopicClient byondTopicSender, IChatTrackingContext chatTrackingContext, IBridgeRegistrar bridgeRegistrar, IChatManager chat, IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, IDotnetDumpService dotnetDumpService, IEventConsumer eventConsumer, ILogger< SessionController > logger, Func< ValueTask > postLifetimeCallback, uint? startupTimeout, bool reattached, bool apiValidate)
Initializes a new instance of the SessionController class.
readonly bool apiValidationSession
If this session is meant to validate the presence of the DMAPI.
FifoSemaphore TopicSendSemaphore
The FifoSemaphore used to prevent concurrent calls into /world/Topic().
async ValueTask UpdateChannels(IEnumerable< ChannelRepresentation > newChannels, CancellationToken cancellationToken)
Called when newChannels are set.A ValueTask representing the running operation.
string GenerateQueryString(TopicParameters parameters, out string json)
Generates a Byond.TopicSender.ITopicClient query string for a given set of parameters .
void CheckDisposed()
Throws an ObjectDisposedException if DisposeAsync has been called.
readonly? IBridgeRegistration bridgeRegistration
The IBridgeRegistration for the SessionController.
long? MemoryUsage
Gets the process' memory usage in bytes.
async Task< LaunchResult > GetLaunchResult(IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, uint? startupTimeout, bool reattached, bool apiValidate)
The Task<TResult> for LaunchResult.
IAsyncDisposable ReplaceDmbProvider(IDmbProvider dmbProvider)
Replace the IDmbProvider in use with a given newProvider , disposing the old one.An IAsyncDisposable ...
Task OnStartup
A Task that completes when the server calls /world/TgsNew().
bool ProcessingRebootBridgeRequest
If the ISessionController is currently processing a bridge request from TgsReboot().
ValueTask Release()
Releases the IProcess without terminating it. Also calls IDisposable.Dispose.A ValueTask representing...
volatile? Task postValidationShutdownTask
Task for shutting down the server if it is taking too long after validation.
volatile Task customEventProcessingTask
The Task representing calls to TriggerCustomEvent(CustomEventInvocation?).
Task OnPrime
A Task that completes when the server calls /world/TgsInitializationComplete().
async ValueTask< CombinedTopicResponse?> SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
Send a topic request for given parameters to DreamDaemon, chunking it if necessary.
volatile TaskCompletionSource rebootTcs
The TaskCompletionSource that completes when DD tells us about a reboot.
async ValueTask< CombinedTopicResponse?> SendRawTopic(string queryString, bool priority, CancellationToken cancellationToken)
Send a given queryString to DreamDaemon's /world/Topic.
volatile TaskCompletionSource primeTcs
The TaskCompletionSource that completes when DD tells us it's primed.
async ValueTask< bool > SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
Attempts to change the current RebootState to newRebootState .A ValueTask<TResult> resulting in true ...
Task RebootGate
A Task that must complete before a TgsReboot() bridge request can complete.
readonly IProcess process
The IProcess for the SessionController.
BridgeResponse TriggerCustomEvent(CustomEventInvocation? invocation)
Trigger a custom event from a given invocation .
RebootState RebootState
The current DreamDaemon reboot state.
ushort Port
The port the game server was last listening on.
A first-in first-out async semaphore.
async ValueTask< SemaphoreSlimContext > Lock(CancellationToken cancellationToken)
Locks the FifoSemaphore.
Helpers for manipulating the Serilog.Context.LogContext.
const string InstanceIdContextProperty
The Serilog.Context.LogContext property name for Models.Instance Api.Models.EntityId....
Notifyee of when ChannelRepresentations in a IChatTrackingContext are updated.
For managing connected chat services.
void QueueMessage(MessageContent message, IEnumerable< ulong > channelIds)
Queue a chat message to a given set of channelIds .
Represents a tracking of dynamic chat json files.
void SetChannelSink(IChannelSink channelSink)
Sets the channelSink for the IChatTrackingContext.
Provides absolute paths to the latest compiled .dmbs.
void KeepAlive()
Disposing the IDmbProvider won't cause a cleanup of the working directory.
EngineVersion EngineVersion
The Api.Models.EngineVersion used to build the .dmb.
Models.CompileJob CompileJob
The CompileJob of the .dmb.
Represents usage of the two primary BYOND server executables.
void DoNotDeleteThisSession()
Call if, during a detach, this version should not be deleted.
bool UseDotnetDump
If dotnet-dump should be used to create process dumps for this installation.
ValueTask StopServerProcess(ILogger logger, IProcess process, string accessIdentifier, ushort port, CancellationToken cancellationToken)
Kills a given engine server process .
Consumes EventTypes and takes the appropriate actions.
ValueTask? HandleCustomEvent(string eventName, IEnumerable< string?> parameters, CancellationToken cancellationToken)
Handles a given custom event.
IBridgeRegistration RegisterHandler(IBridgeHandler bridgeHandler)
Register a given bridgeHandler .
Handles communication with a DreamDaemon IProcess.
Service for managing the dotnet-dump installation.
ValueTask Dump(IProcess process, string outputFile, bool minidump, CancellationToken cancellationToken)
Attempt to dump a given process .
void SuspendProcess()
Suspends the process.
void ResumeProcess()
Resumes the process.
void AdjustPriority(bool higher)
Set's the owned global::System.Diagnostics.Process.PriorityClass to a non-normal value.
long? MemoryUsage
Gets the process' memory usage in bytes.
DateTimeOffset? LaunchTime
When the process was started.
ValueTask CreateDump(string outputFile, bool minidump, CancellationToken cancellationToken)
Create a dump file of the process.
Task< int?> Lifetime
The Task<TResult> resulting in the exit code of the process or null if the process was detached.
Abstraction over a global::System.Diagnostics.Process.
Definition IProcess.cs:11
Task Startup
The Task representing the time until the IProcess becomes "idle".
Definition IProcess.cs:20
void Terminate()
Asycnhronously terminates the process.
ValueTask Delay(TimeSpan timeSpan, CancellationToken cancellationToken)
Create a Task that completes after a given timeSpan .
DreamDaemonSecurity
DreamDaemon's security level.
EngineType
The type of engine the codebase is using.
Definition EngineType.cs:7
@ Byond
Build your own net dream.
BridgeCommandType
Represents the BridgeParameters.CommandType.
@ Chunk
DreamDaemon attempting to send a longer bridge message.
RebootState
Represents the action to take when /world/Reboot() is called.
Definition RebootState.cs:7
ApiValidationStatus
Status of DMAPI validation.