tgstation-server 6.19.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 long? StartupBridgeRequestsReceived { get; private set; }
84
86 public Task RebootGate
87 {
88 get => rebootGate;
89 set
90 {
91 var tcs = new TaskCompletionSource<Task>();
92 async Task Wrap()
93 {
94 var toAwait = await tcs.Task;
95 await toAwait;
96 await value;
97 }
98
99 tcs.SetResult(Interlocked.Exchange(ref rebootGate, Wrap()));
100 }
101 }
102
104 public Task OnPrime => primeTcs.Task;
105
108
111
113 public string DumpFileExtension => engineLock.UseDotnetDump
114 ? ".net.dmp"
115 : ".dmp";
116
121
126
129
131 public DateTimeOffset? LaunchTime => process.LaunchTime;
132
136 readonly Byond.TopicSender.ITopicClient byondTopicSender;
137
142
147
152
157
162
167
172
177
181 readonly TaskCompletionSource initialBridgeRequestTcs;
182
187
191 readonly CancellationTokenSource sessionDurationCts;
192
196 readonly object synchronizationLock;
197
201 readonly bool apiValidationSession;
202
206 volatile TaskCompletionSource startupTcs;
207
211 volatile TaskCompletionSource rebootTcs;
212
216 volatile TaskCompletionSource primeTcs;
217
221 volatile Task rebootGate;
222
227
232
237
242
247
252
257
279 ReattachInformation reattachInformation,
280 Api.Models.Instance metadata,
283 Byond.TopicSender.ITopicClient byondTopicSender,
285 IBridgeRegistrar bridgeRegistrar,
287 IAssemblyInformationProvider assemblyInformationProvider,
291 ILogger<SessionController> logger,
292 Func<ValueTask> postLifetimeCallback,
293 uint? startupTimeout,
294 bool reattached,
295 bool apiValidate)
296 : base(logger)
297 {
298 ReattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
299 this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
300 this.process = process ?? throw new ArgumentNullException(nameof(process));
301 this.engineLock = engineLock ?? throw new ArgumentNullException(nameof(engineLock));
302 this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
303 this.chatTrackingContext = chatTrackingContext ?? throw new ArgumentNullException(nameof(chatTrackingContext));
304 ArgumentNullException.ThrowIfNull(bridgeRegistrar);
305
306 this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
307 ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
308
309 this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
310 this.dotnetDumpService = dotnetDumpService ?? throw new ArgumentNullException(nameof(dotnetDumpService));
311 this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
312
313 apiValidationSession = apiValidate;
314
315 disposed = false;
317 released = false;
318
319 startupTcs = new TaskCompletionSource();
320 rebootTcs = new TaskCompletionSource();
321 primeTcs = new TaskCompletionSource();
322
323 rebootGate = Task.CompletedTask;
324 customEventProcessingTask = Task.CompletedTask;
325
326 // Run this asynchronously because we want to try to avoid any effects sending topics to the server while the initial bridge request is processing
327 // It MAY be the source of a DD crash. See this gist https://gist.github.com/Cyberboss/7776bbeff3a957d76affe0eae95c9f14
328 // Worth further investigation as to if that sequence of events is a reliable crash vector and opening a BYOND bug if it is
329 initialBridgeRequestTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
330 sessionDurationCts = new CancellationTokenSource();
331
333 synchronizationLock = new object();
334
335 if (DMApiAvailable)
336 {
338 }
339
341 {
342 bridgeRegistration = bridgeRegistrar.RegisterHandler(this);
343 this.chatTrackingContext.SetChannelSink(this);
344 }
345 else
346 logger.LogTrace(
347 "Not registering session with {reasonWhyDmApiIsBad} DMAPI version for interop!",
348 reattachInformation.Dmb.CompileJob.DMApiVersion == null
349 ? "no"
350 : $"incompatible ({reattachInformation.Dmb.CompileJob.DMApiVersion})");
351
352 async Task<int?> WrapLifetime()
353 {
354 var exitCode = await process.Lifetime;
355 await postLifetimeCallback();
356 if (postValidationShutdownTask != null)
358
359 return exitCode;
360 }
361
362 Lifetime = WrapLifetime();
363
365 assemblyInformationProvider,
367 startupTimeout,
368 reattached,
369 apiValidate);
370
371 logger.LogDebug(
372 "Created session controller. CommsKey: {accessIdentifier}, Port: {port}",
373 reattachInformation.AccessIdentifier,
374 reattachInformation.Port);
375 }
376
378 public async ValueTask DisposeAsync()
379 {
381 {
382 if (disposed)
383 return;
384 disposed = true;
385 }
386
387 Logger.LogTrace("Disposing...");
388
389 sessionDurationCts.Cancel();
390 var cancellationToken = CancellationToken.None; // DCT: None available
391 var semaphoreLockTask = TopicSendSemaphore.Lock(cancellationToken);
392
393 if (!released)
394 {
396 Logger,
397 process,
400 cancellationToken);
401 }
402
403 await process.DisposeAsync();
404 engineLock.Dispose();
405 bridgeRegistration?.Dispose();
406 var regularDmbDisposeTask = ReattachInformation.Dmb.DisposeAsync();
407 var initialDmb = ReattachInformation.InitialDmb;
408 if (initialDmb != null)
409 await initialDmb.DisposeAsync();
410
411 await regularDmbDisposeTask;
412
413 chatTrackingContext.Dispose();
414 sessionDurationCts.Dispose();
415
416 if (!released)
417 await Lifetime; // finish the async callback
418
419 (await semaphoreLockTask).Dispose();
421
423 }
424
426 public async ValueTask<BridgeResponse?> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
427 {
428 ArgumentNullException.ThrowIfNull(parameters);
429
430 using (LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, metadata.Id))
431 {
433 {
434 Logger.LogWarning("Ignoring bridge request from session without confirmed DMAPI!");
435 return null;
436 }
437
438 Logger.LogTrace("Handling bridge request...");
439
440 try
441 {
442 return await ProcessBridgeCommand(parameters, cancellationToken);
443 }
444 finally
445 {
446 initialBridgeRequestTcs.TrySetResult();
447 }
448 }
449 }
450
452 public ValueTask Release()
453 {
455
459 released = true;
460 return DisposeAsync();
461 }
462
464 public ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
465 => SendCommand(parameters, false, cancellationToken);
466
468 public async ValueTask<bool> SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
469 {
470 if (RebootState == newRebootState)
471 return true;
472
473 Logger.LogTrace("Changing reboot state to {newRebootState}", newRebootState);
474
475 ReattachInformation.RebootState = newRebootState;
476 var result = await SendCommand(
477 new TopicParameters(newRebootState),
478 cancellationToken);
479
480 return result != null && result.ErrorMessage == null;
481 }
482
484 public void ResetRebootState()
485 {
487 Logger.LogTrace("Resetting reboot state...");
488 ReattachInformation.RebootState = RebootState.Normal;
489 }
490
492 public void AdjustPriority(bool higher) => process.AdjustPriority(higher);
493
496
499
502 {
503 var oldDmb = ReattachInformation.Dmb;
504 ReattachInformation.Dmb = dmbProvider ?? throw new ArgumentNullException(nameof(dmbProvider));
505 return oldDmb;
506 }
507
509 public async ValueTask InstanceRenamed(string newInstanceName, CancellationToken cancellationToken)
510 {
511 var runtimeInformation = ReattachInformation.RuntimeInformation;
512 if (runtimeInformation != null)
513 runtimeInformation.InstanceName = newInstanceName;
514
515 await SendCommand(
517 cancellationToken);
518 }
519
521 public async ValueTask UpdateChannels(IEnumerable<ChannelRepresentation> newChannels, CancellationToken cancellationToken)
522 => await SendCommand(
523 new TopicParameters(
524 new ChatUpdate(newChannels)),
525 cancellationToken);
526
528 public ValueTask CreateDump(string outputFile, bool minidump, CancellationToken cancellationToken)
529 {
531 return dotnetDumpService.Dump(process, outputFile, minidump, cancellationToken);
532
533 return process.CreateDump(outputFile, minidump, cancellationToken);
534 }
535
539
549 async Task<LaunchResult> GetLaunchResult(
550 IAssemblyInformationProvider assemblyInformationProvider,
552 uint? startupTimeout,
553 bool reattached,
554 bool apiValidate)
555 {
556 var startTime = DateTimeOffset.UtcNow;
557 var useBridgeRequestForLaunchResult = !reattached && (apiValidate || DMApiAvailable);
558 var startupTask = useBridgeRequestForLaunchResult
559 ? initialBridgeRequestTcs.Task
561 var toAwait = Task.WhenAny(startupTask, process.Lifetime);
562
563 if (startupTimeout.HasValue)
564 toAwait = Task.WhenAny(
565 toAwait,
567 TimeSpan.FromSeconds(startupTimeout.Value),
568 CancellationToken.None)
569 .AsTask()); // DCT: None available, task will clean up after delay
570
571 Logger.LogTrace(
572 "Waiting for LaunchResult based on {launchResultCompletionCause}{possibleTimeout}...",
573 useBridgeRequestForLaunchResult ? "initial bridge request" : "process startup",
574 startupTimeout.HasValue ? $" with a timeout of {startupTimeout.Value}s" : String.Empty);
575
576 await toAwait;
577
578 var result = new LaunchResult
579 {
580 ExitCode = process.Lifetime.IsCompleted ? await process.Lifetime : null,
581 StartupTime = startupTask.IsCompleted ? (DateTimeOffset.UtcNow - startTime) : null,
582 };
583
584 Logger.LogTrace("Launch result: {launchResult}", result);
585
586 if (!result.ExitCode.HasValue && reattached && !disposed)
587 {
588 var reattachResponse = await SendCommand(
589 new TopicParameters(
590 assemblyInformationProvider.Version,
592 true,
593 sessionDurationCts.Token);
594
595 if (reattachResponse != null)
596 {
597 if (reattachResponse?.CustomCommands != null)
598 chatTrackingContext.CustomCommands = reattachResponse.CustomCommands;
599 else if (reattachResponse != null)
600 Logger.Log(
601 CompileJob.DMApiVersion >= new Version(5, 2, 0)
602 ? LogLevel.Warning
603 : LogLevel.Debug,
604 "DMAPI Interop v{interopVersion} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
605 CompileJob.DMApiVersion!.Semver());
606 }
607 }
608
609 return result;
610 }
611
615 void CheckDisposed() => ObjectDisposedException.ThrowIf(disposed, this);
616
622 async Task PostValidationShutdown(Task<bool> proceedTask)
623 {
624 Logger.LogTrace("Entered post validation terminate task.");
625 if (!await proceedTask)
626 {
627 Logger.LogTrace("Not running post validation terminate task for repeated bridge request.");
628 return;
629 }
630
631 const int GracePeriodSeconds = 30;
632 Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds);
633 var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None).AsTask(); // DCT: None available
634 await Task.WhenAny(process.Lifetime, delayTask);
635
636 if (!process.Lifetime.IsCompleted)
637 {
638 Logger.LogWarning("DMAPI took too long to shutdown server after validation request!");
640 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
641 }
642 else
643 Logger.LogTrace("Server exited properly post validation.");
644 }
645
652#pragma warning disable CA1502 // TODO: Decomplexify
653 async ValueTask<BridgeResponse?> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
654 {
655 var response = new BridgeResponse();
656 switch (parameters.CommandType)
657 {
658 case BridgeCommandType.ChatSend:
659 if (parameters.ChatMessage == null)
660 return BridgeError("Missing chatMessage field!");
661
662 if (parameters.ChatMessage.ChannelIds == null)
663 return BridgeError("Missing channelIds field in chatMessage!");
664
665 if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
666 return BridgeError("Invalid channelIds in chatMessage!");
667
668 if (parameters.ChatMessage.Text == null)
669 return BridgeError("Missing message field in chatMessage!");
670
671 var anyFailed = false;
672 var parsedChannels = parameters.ChatMessage.ChannelIds.Select(
673 channelString =>
674 {
675 anyFailed |= !UInt64.TryParse(channelString, out var channelId);
676 return channelId;
677 });
678
679 if (anyFailed)
680 return BridgeError("Failed to parse channelIds as U64!");
681
683 parameters.ChatMessage,
684 parsedChannels);
685 break;
686 case BridgeCommandType.Prime:
687 Interlocked.Exchange(ref primeTcs, new TaskCompletionSource()).SetResult();
688 break;
689 case BridgeCommandType.Kill:
690 Logger.LogInformation("Bridge requested process termination!");
691 chatTrackingContext.Active = false;
694 break;
695 case BridgeCommandType.DeprecatedPortUpdate:
696 return BridgeError("Port switching is no longer supported!");
697 case BridgeCommandType.Startup:
698 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
700
702 {
703 var proceedTcs = new TaskCompletionSource<bool>();
704 var firstValidationRequest = Interlocked.CompareExchange(ref postValidationShutdownTask, PostValidationShutdown(proceedTcs.Task), null) == null;
705 proceedTcs.SetResult(firstValidationRequest);
706
707 if (!firstValidationRequest)
708 return BridgeError("Startup bridge request was repeated!");
709 }
710
711 if (parameters.Version == null)
712 return BridgeError("Missing dmApiVersion field!");
713
714 DMApiVersion = parameters.Version;
715
716 // TODO: When OD figures out how to unite port and topic_port, set an upper version bound on OD for this check
718 || (EngineVersion.Engine == EngineType.OpenDream && DMApiVersion < new Version(5, 7)))
719 {
721 return BridgeError("Incompatible dmApiVersion!");
722 }
723
724 switch (parameters.MinimumSecurityLevel)
725 {
726 case DreamDaemonSecurity.Ultrasafe:
727 apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
728 break;
729 case DreamDaemonSecurity.Safe:
731 break;
732 case DreamDaemonSecurity.Trusted:
734 break;
735 case null:
736 return BridgeError("Missing minimumSecurityLevel field!");
737 default:
738 return BridgeError("Invalid minimumSecurityLevel!");
739 }
740
741 Logger.LogTrace("ApiValidationStatus set to {apiValidationStatus}", apiValidationStatus);
742
743 // we create new runtime info here because of potential .Dmb changes (i think. i forget...)
744 response.RuntimeInformation = new RuntimeInformation(
753
754 if (parameters.TopicPort.HasValue)
755 {
756 var newTopicPort = parameters.TopicPort.Value;
757 Logger.LogInformation("Server is requesting use of port {topicPort} for topic communications", newTopicPort);
758 ReattachInformation.TopicPort = newTopicPort;
759 }
760
761 // Load custom commands
762 chatTrackingContext.CustomCommands = parameters.CustomCommands ?? Array.Empty<CustomCommand>();
763 chatTrackingContext.Active = true;
764 Interlocked.Exchange(ref startupTcs, new TaskCompletionSource()).SetResult();
765 break;
766 case BridgeCommandType.Reboot:
767 Interlocked.Increment(ref rebootBridgeRequestsProcessing);
768 try
769 {
770 chatTrackingContext.Active = false;
771 var rebootGate = RebootGate; // Important to read this before setting the TCS or it could change
772 Interlocked.Exchange(ref rebootTcs, new TaskCompletionSource()).SetResult();
773 await rebootGate.WaitAsync(cancellationToken);
774 }
775 finally
776 {
777 Interlocked.Decrement(ref rebootBridgeRequestsProcessing);
778 }
779
780 break;
781 case BridgeCommandType.Chunk:
782 return await ProcessChunk<BridgeParameters, BridgeResponse>(ProcessBridgeCommand, BridgeError, parameters.Chunk, cancellationToken);
783 case BridgeCommandType.Event:
784 return TriggerCustomEvent(parameters.EventInvocation);
785 case null:
786 return BridgeError("Missing commandType!");
787 default:
788 return BridgeError($"commandType {parameters.CommandType} not supported!");
789 }
790
791 return response;
792 }
793#pragma warning restore CA1502
794
801 {
802 Logger.LogWarning("Bridge request error: {message}", message);
803 return new BridgeResponse
804 {
805 ErrorMessage = message,
806 };
807 }
808
815 async ValueTask<CombinedTopicResponse?> SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
816 {
817 parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
818
819 var fullCommandString = GenerateQueryString(parameters, out var json);
820 if (LogTopicRequests)
821 Logger.LogTrace("Topic request: {json}", json);
822 var fullCommandByteCount = Encoding.UTF8.GetByteCount(fullCommandString);
823 var topicPriority = parameters.IsPriority;
824 if (fullCommandByteCount <= DMApiConstants.MaximumTopicRequestLength)
825 return await SendRawTopic(fullCommandString, topicPriority, cancellationToken);
826
827 var interopChunkingVersion = new Version(5, 6, 0);
828 if (ReattachInformation.Dmb.CompileJob.DMApiVersion < interopChunkingVersion)
829 {
830 Logger.LogWarning(
831 "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}!",
833 fullCommandByteCount,
834 interopChunkingVersion);
835 return null;
836 }
837
838 var payloadId = NextPayloadId;
839
840 // AccessIdentifer is just noise in a chunked request
841 parameters.AccessIdentifier = null!;
842 GenerateQueryString(parameters, out json);
843
844 // yes, this straight up ignores unicode, precalculating it is useless when we don't
845 // even know if the UTF8 bytes of the url encoded chunk will fit the window until we do said encoding
846 var fullPayloadSize = (uint)json.Length;
847
848 List<string>? chunkQueryStrings = null;
849 for (var chunkCount = 2; chunkQueryStrings == null; ++chunkCount)
850 {
851 var standardChunkSize = fullPayloadSize / chunkCount;
852 var bigChunkSize = standardChunkSize + (fullPayloadSize % chunkCount);
853 if (bigChunkSize > DMApiConstants.MaximumTopicRequestLength)
854 continue;
855
856 chunkQueryStrings = new List<string>();
857 for (var i = 0U; i < chunkCount; ++i)
858 {
859 var startIndex = i * standardChunkSize;
860 var subStringLength = Math.Min(
861 fullPayloadSize - startIndex,
862 i == chunkCount - 1
863 ? bigChunkSize
864 : standardChunkSize);
865 var chunkPayload = json.Substring((int)startIndex, (int)subStringLength);
866
867 var chunk = new ChunkData
868 {
869 Payload = chunkPayload,
870 PayloadId = payloadId,
871 SequenceId = i,
872 TotalChunks = (uint)chunkCount,
873 };
874
875 var chunkParameters = new TopicParameters(chunk)
876 {
877 AccessIdentifier = ReattachInformation.AccessIdentifier,
878 };
879
880 var chunkCommandString = GenerateQueryString(chunkParameters, out _);
881 if (Encoding.UTF8.GetByteCount(chunkCommandString) > DMApiConstants.MaximumTopicRequestLength)
882 {
883 // too long when encoded, need more chunks
884 chunkQueryStrings = null;
885 break;
886 }
887
888 chunkQueryStrings.Add(chunkCommandString);
889 }
890 }
891
892 Logger.LogTrace("Chunking topic request ({totalChunks} total)...", chunkQueryStrings.Count);
893
894 CombinedTopicResponse? combinedResponse = null;
895 bool LogRequestIssue(bool possiblyFromCompletedRequest)
896 {
897 if (combinedResponse?.InteropResponse == null || combinedResponse.InteropResponse.ErrorMessage != null)
898 {
899 Logger.LogWarning(
900 "Topic request {chunkingStatus} failed!{potentialRequestError}",
901 possiblyFromCompletedRequest ? "final chunk" : "chunking",
902 combinedResponse?.InteropResponse?.ErrorMessage != null
903 ? $" Request error: {combinedResponse.InteropResponse.ErrorMessage}"
904 : String.Empty);
905 return true;
906 }
907
908 return false;
909 }
910
911 foreach (var chunkCommandString in chunkQueryStrings)
912 {
913 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
914 if (LogRequestIssue(chunkCommandString == chunkQueryStrings.Last()))
915 return null;
916 }
917
918 while ((combinedResponse?.InteropResponse?.MissingChunks?.Count ?? 0) > 0)
919 {
920 Logger.LogWarning("DD is still missing some chunks of topic request P{payloadId}! Sending missing chunks...", payloadId);
921 var missingChunks = combinedResponse!.InteropResponse!.MissingChunks!;
922 var lastIndex = missingChunks.Last();
923 foreach (var missingChunkIndex in missingChunks)
924 {
925 var chunkCommandString = chunkQueryStrings[(int)missingChunkIndex];
926 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
927 if (LogRequestIssue(missingChunkIndex == lastIndex))
928 return null;
929 }
930 }
931
932 return combinedResponse;
933 }
934
941 string GenerateQueryString(TopicParameters parameters, out string json)
942 {
943 json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
944 var commandString = String.Format(
945 CultureInfo.InvariantCulture,
946 "?{0}={1}",
948 byondTopicSender.SanitizeString(json));
949 return commandString;
950 }
951
959 async ValueTask<CombinedTopicResponse?> SendRawTopic(string queryString, bool priority, CancellationToken cancellationToken)
960 {
961 if (disposed)
962 {
963 Logger.LogWarning(
964 "Attempted to send a topic on a disposed SessionController");
965 return null;
966 }
967
968 var targetPort = ReattachInformation.TopicPort ?? ReattachInformation.Port;
969 Byond.TopicSender.TopicResponse? byondResponse;
970 using (await TopicSendSemaphore.Lock(cancellationToken))
971 byondResponse = await byondTopicSender.SendWithOptionalPriority(
973 LogTopicRequests
974 ? Logger
975 : NullLogger.Instance,
976 queryString,
977 targetPort,
978 priority,
979 cancellationToken);
980
981 if (byondResponse == null)
982 {
983 if (priority)
984 Logger.LogError(
985 "Unable to send priority topic \"{queryString}\"!",
986 queryString);
987
988 return null;
989 }
990
991 var topicReturn = byondResponse.StringData;
992
993 TopicResponse? interopResponse = null;
994 if (topicReturn != null)
995 try
996 {
997 interopResponse = JsonConvert.DeserializeObject<TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
998 }
999 catch (Exception ex)
1000 {
1001 Logger.LogWarning(ex, "Invalid interop response: {topicReturnString}", topicReturn);
1002 }
1003
1004 return new CombinedTopicResponse(byondResponse, interopResponse);
1005 }
1006
1014 async ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, bool bypassLaunchResult, CancellationToken cancellationToken)
1015 {
1016 ArgumentNullException.ThrowIfNull(parameters);
1017
1018 if (Lifetime.IsCompleted || disposed)
1019 {
1020 Logger.LogWarning(
1021 "Attempted to send a command to an inactive SessionController: {commandType}",
1022 parameters.CommandType);
1023 return null;
1024 }
1025
1026 if (!DMApiAvailable)
1027 {
1028 Logger.LogTrace("Not sending topic request {commandType} to server without/with incompatible DMAPI!", parameters.CommandType);
1029 return null;
1030 }
1031
1032 var reboot = OnReboot;
1033 if (!bypassLaunchResult)
1034 {
1035 var launchResult = await LaunchResult.WaitAsync(cancellationToken);
1036 if (launchResult.ExitCode.HasValue)
1037 {
1038 Logger.LogDebug("Not sending topic request {commandType} to server that failed to launch!", parameters.CommandType);
1039 return null;
1040 }
1041 }
1042
1043 // meh, this is kind of a hack, but it works
1045 {
1046 Logger.LogDebug("Not sending topic request {commandType} to server that is rebooting/starting.", parameters.CommandType);
1047 return null;
1048 }
1049
1050 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1051 var combinedCancellationToken = cts.Token;
1052 async ValueTask CancelIfLifetimeElapses()
1053 {
1054 try
1055 {
1056 var completed = await Task.WhenAny(Lifetime, reboot).WaitAsync(combinedCancellationToken);
1057
1058 Logger.LogDebug(
1059 "Server {action}, cancelling pending command: {commandType}",
1060 completed != reboot
1061 ? "process ended"
1062 : "rebooting",
1063 parameters.CommandType);
1064 cts.Cancel();
1065 }
1066 catch (OperationCanceledException)
1067 {
1068 // expected, not even worth tracing
1069 }
1070 catch (Exception ex)
1071 {
1072 Logger.LogError(ex, "Error in CancelIfLifetimeElapses!");
1073 }
1074 }
1075
1076 TopicResponse? fullResponse = null;
1077 var lifetimeWatchingTask = CancelIfLifetimeElapses();
1078 try
1079 {
1080 var combinedResponse = await SendTopicRequest(parameters, combinedCancellationToken);
1081
1082 void LogCombinedResponse()
1083 {
1084 if (LogTopicRequests && combinedResponse != null)
1085 Logger.LogTrace("Topic response: {topicString}", combinedResponse.ByondTopicResponse.StringData ?? "(NO STRING DATA)");
1086 }
1087
1088 LogCombinedResponse();
1089
1090 if (combinedResponse?.InteropResponse?.Chunk != null)
1091 {
1092 Logger.LogTrace("Topic response is chunked...");
1093
1094 ChunkData? nextChunk = combinedResponse.InteropResponse.Chunk;
1095 do
1096 {
1097 var nextRequest = await ProcessChunk<TopicResponse, ChunkedTopicParameters>(
1098 (completedResponse, _) =>
1099 {
1100 fullResponse = completedResponse;
1101 return ValueTask.FromResult<ChunkedTopicParameters?>(null);
1102 },
1103 error =>
1104 {
1105 Logger.LogWarning("Topic response chunking error: {message}", error);
1106 return null;
1107 },
1108 combinedResponse?.InteropResponse?.Chunk,
1109 combinedCancellationToken);
1110
1111 if (nextRequest != null)
1112 {
1113 nextRequest.PayloadId = nextChunk.PayloadId;
1114 combinedResponse = await SendTopicRequest(nextRequest, combinedCancellationToken);
1115 LogCombinedResponse();
1116 nextChunk = combinedResponse?.InteropResponse?.Chunk;
1117 }
1118 else
1119 nextChunk = null;
1120 }
1121 while (nextChunk != null);
1122 }
1123 else
1124 fullResponse = combinedResponse?.InteropResponse;
1125 }
1126 catch (OperationCanceledException ex)
1127 {
1128 Logger.LogDebug(
1129 ex,
1130 "Topic request {cancellationType}!",
1131 combinedCancellationToken.IsCancellationRequested
1132 ? cancellationToken.IsCancellationRequested
1133 ? "cancelled"
1134 : "aborted"
1135 : "timed out");
1136
1137 // throw only if the original token was the trigger
1138 cancellationToken.ThrowIfCancellationRequested();
1139 }
1140 finally
1141 {
1142 cts.Cancel();
1143 await lifetimeWatchingTask;
1144 }
1145
1146 if (fullResponse?.ErrorMessage != null)
1147 Logger.LogWarning(
1148 "Errored topic response for command {commandType}: {errorMessage}",
1149 parameters.CommandType,
1150 fullResponse.ErrorMessage);
1151
1152 return fullResponse;
1153 }
1154
1161 {
1162 if (invocation == null)
1163 return BridgeError("Missing eventInvocation!");
1164
1165 var eventName = invocation.EventName;
1166 if (eventName == null)
1167 return BridgeError("Missing eventName!");
1168
1169 var notifyCompletion = invocation.NotifyCompletion;
1170 if (!notifyCompletion.HasValue)
1171 return BridgeError("Missing notifyCompletion!");
1172
1173 var eventParams = new List<string>
1174 {
1176 };
1177
1178 eventParams.AddRange(invocation
1179 .Parameters?
1180 .Where(param => param != null)
1181 .Cast<string>()
1182 ?? Enumerable.Empty<string>());
1183
1184 var eventId = Guid.NewGuid();
1185 Logger.LogInformation("Triggering custom event \"{eventName}\": {eventId}", eventName, eventId);
1186
1187 var cancellationToken = sessionDurationCts.Token;
1188 ValueTask? eventTask = eventConsumer.HandleCustomEvent(eventName, eventParams, cancellationToken);
1189
1190 async Task ProcessEvent()
1191 {
1192 try
1193 {
1194 Exception? exception;
1195 try
1196 {
1197 await eventTask.Value;
1198 exception = null;
1199 }
1200 catch (Exception ex)
1201 {
1202 exception = ex;
1203 }
1204
1205 if (notifyCompletion.Value)
1206 await SendCommand(
1207 new TopicParameters(eventId),
1208 cancellationToken);
1209 else if (exception == null)
1210 Logger.LogTrace("Finished custom event {eventId}, not sending notification.", eventId);
1211
1212 if (exception != null)
1213 throw exception;
1214 }
1215 catch (OperationCanceledException ex)
1216 {
1217 Logger.LogDebug(ex, "Custom event invocation {eventId} aborted!", eventId);
1218 }
1219 catch (Exception ex)
1220 {
1221 Logger.LogWarning(ex, "Custom event invocation {eventId} errored!", eventId);
1222 }
1223 }
1224
1225 if (!eventTask.HasValue)
1226 return BridgeError("Event refused to execute due to matching a TGS event!");
1227
1228 lock (sessionDurationCts)
1229 {
1230 var previousEventProcessingTask = customEventProcessingTask;
1231 var eventProcessingTask = ProcessEvent();
1232 customEventProcessingTask = Task.WhenAll(customEventProcessingTask, eventProcessingTask);
1233 }
1234
1235 return new BridgeResponse
1236 {
1237 EventId = notifyCompletion.Value
1238 ? eventId.ToString()
1239 : null,
1240 };
1241 }
1242 }
1243}
Information about an engine installation.
Metadata about a server instance.
Definition Instance.cs:9
virtual ? Version DMApiVersion
The DMAPI Version.
Definition CompileJob.cs:43
ChatMessage? ChatMessage
The Interop.ChatMessage for BridgeCommandType.ChatSend requests.
CustomEventInvocation? EventInvocation
The 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.
long? StartupBridgeRequestsReceived
The number of times a startup bridge request has been received. null if DMApiAvailable is false.
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.
double MeasureProcessorTimeDelta()
Gets the estimated CPU usage fraction of the process based on the last time this was called....
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.
double MeasureProcessorTimeDelta()
Gets the estimated CPU usage fraction of the process based on the last time this was called.
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.