tgstation-server 6.14.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
525
535 async Task<LaunchResult> GetLaunchResult(
536 IAssemblyInformationProvider assemblyInformationProvider,
538 uint? startupTimeout,
539 bool reattached,
540 bool apiValidate)
541 {
542 var startTime = DateTimeOffset.UtcNow;
543 var useBridgeRequestForLaunchResult = !reattached && (apiValidate || DMApiAvailable);
544 var startupTask = useBridgeRequestForLaunchResult
545 ? initialBridgeRequestTcs.Task
547 var toAwait = Task.WhenAny(startupTask, process.Lifetime);
548
549 if (startupTimeout.HasValue)
550 toAwait = Task.WhenAny(
551 toAwait,
553 TimeSpan.FromSeconds(startupTimeout.Value),
554 CancellationToken.None)
555 .AsTask()); // DCT: None available, task will clean up after delay
556
557 Logger.LogTrace(
558 "Waiting for LaunchResult based on {launchResultCompletionCause}{possibleTimeout}...",
559 useBridgeRequestForLaunchResult ? "initial bridge request" : "process startup",
560 startupTimeout.HasValue ? $" with a timeout of {startupTimeout.Value}s" : String.Empty);
561
562 await toAwait;
563
564 var result = new LaunchResult
565 {
566 ExitCode = process.Lifetime.IsCompleted ? await process.Lifetime : null,
567 StartupTime = startupTask.IsCompleted ? (DateTimeOffset.UtcNow - startTime) : null,
568 };
569
570 Logger.LogTrace("Launch result: {launchResult}", result);
571
572 if (!result.ExitCode.HasValue && reattached && !disposed)
573 {
574 var reattachResponse = await SendCommand(
575 new TopicParameters(
576 assemblyInformationProvider.Version,
578 true,
579 sessionDurationCts.Token);
580
581 if (reattachResponse != null)
582 {
583 if (reattachResponse?.CustomCommands != null)
584 chatTrackingContext.CustomCommands = reattachResponse.CustomCommands;
585 else if (reattachResponse != null)
586 Logger.Log(
587 CompileJob.DMApiVersion >= new Version(5, 2, 0)
588 ? LogLevel.Warning
589 : LogLevel.Debug,
590 "DMAPI Interop v{interopVersion} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
591 CompileJob.DMApiVersion!.Semver());
592 }
593 }
594
595 return result;
596 }
597
601 void CheckDisposed() => ObjectDisposedException.ThrowIf(disposed, this);
602
608 async Task PostValidationShutdown(Task<bool> proceedTask)
609 {
610 Logger.LogTrace("Entered post validation terminate task.");
611 if (!await proceedTask)
612 {
613 Logger.LogTrace("Not running post validation terminate task for repeated bridge request.");
614 return;
615 }
616
617 const int GracePeriodSeconds = 30;
618 Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds);
619 var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None).AsTask(); // DCT: None available
620 await Task.WhenAny(process.Lifetime, delayTask);
621
622 if (!process.Lifetime.IsCompleted)
623 {
624 Logger.LogWarning("DMAPI took too long to shutdown server after validation request!");
626 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
627 }
628 else
629 Logger.LogTrace("Server exited properly post validation.");
630 }
631
638#pragma warning disable CA1502 // TODO: Decomplexify
639 async ValueTask<BridgeResponse?> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
640 {
641 var response = new BridgeResponse();
642 switch (parameters.CommandType)
643 {
644 case BridgeCommandType.ChatSend:
645 if (parameters.ChatMessage == null)
646 return BridgeError("Missing chatMessage field!");
647
648 if (parameters.ChatMessage.ChannelIds == null)
649 return BridgeError("Missing channelIds field in chatMessage!");
650
651 if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
652 return BridgeError("Invalid channelIds in chatMessage!");
653
654 if (parameters.ChatMessage.Text == null)
655 return BridgeError("Missing message field in chatMessage!");
656
657 var anyFailed = false;
658 var parsedChannels = parameters.ChatMessage.ChannelIds.Select(
659 channelString =>
660 {
661 anyFailed |= !UInt64.TryParse(channelString, out var channelId);
662 return channelId;
663 });
664
665 if (anyFailed)
666 return BridgeError("Failed to parse channelIds as U64!");
667
669 parameters.ChatMessage,
670 parsedChannels);
671 break;
672 case BridgeCommandType.Prime:
673 Interlocked.Exchange(ref primeTcs, new TaskCompletionSource()).SetResult();
674 break;
675 case BridgeCommandType.Kill:
676 Logger.LogInformation("Bridge requested process termination!");
677 chatTrackingContext.Active = false;
680 break;
681 case BridgeCommandType.DeprecatedPortUpdate:
682 return BridgeError("Port switching is no longer supported!");
683 case BridgeCommandType.Startup:
684 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
685
687 {
688 var proceedTcs = new TaskCompletionSource<bool>();
689 var firstValidationRequest = Interlocked.CompareExchange(ref postValidationShutdownTask, PostValidationShutdown(proceedTcs.Task), null) == null;
690 proceedTcs.SetResult(firstValidationRequest);
691
692 if (!firstValidationRequest)
693 return BridgeError("Startup bridge request was repeated!");
694 }
695
696 if (parameters.Version == null)
697 return BridgeError("Missing dmApiVersion field!");
698
699 DMApiVersion = parameters.Version;
700
701 // TODO: When OD figures out how to unite port and topic_port, set an upper version bound on OD for this check
703 || (EngineVersion.Engine == EngineType.OpenDream && DMApiVersion < new Version(5, 7)))
704 {
706 return BridgeError("Incompatible dmApiVersion!");
707 }
708
709 switch (parameters.MinimumSecurityLevel)
710 {
711 case DreamDaemonSecurity.Ultrasafe:
712 apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
713 break;
714 case DreamDaemonSecurity.Safe:
716 break;
717 case DreamDaemonSecurity.Trusted:
719 break;
720 case null:
721 return BridgeError("Missing minimumSecurityLevel field!");
722 default:
723 return BridgeError("Invalid minimumSecurityLevel!");
724 }
725
726 Logger.LogTrace("ApiValidationStatus set to {apiValidationStatus}", apiValidationStatus);
727
728 // we create new runtime info here because of potential .Dmb changes (i think. i forget...)
729 response.RuntimeInformation = new RuntimeInformation(
738
739 if (parameters.TopicPort.HasValue)
740 {
741 var newTopicPort = parameters.TopicPort.Value;
742 Logger.LogInformation("Server is requesting use of port {topicPort} for topic communications", newTopicPort);
743 ReattachInformation.TopicPort = newTopicPort;
744 }
745
746 // Load custom commands
747 chatTrackingContext.CustomCommands = parameters.CustomCommands ?? Array.Empty<CustomCommand>();
748 chatTrackingContext.Active = true;
749 Interlocked.Exchange(ref startupTcs, new TaskCompletionSource()).SetResult();
750 break;
751 case BridgeCommandType.Reboot:
752 Interlocked.Increment(ref rebootBridgeRequestsProcessing);
753 try
754 {
755 chatTrackingContext.Active = false;
756 Interlocked.Exchange(ref rebootTcs, new TaskCompletionSource()).SetResult();
757 await RebootGate.WaitAsync(cancellationToken);
758 }
759 finally
760 {
761 Interlocked.Decrement(ref rebootBridgeRequestsProcessing);
762 }
763
764 break;
765 case BridgeCommandType.Chunk:
766 return await ProcessChunk<BridgeParameters, BridgeResponse>(ProcessBridgeCommand, BridgeError, parameters.Chunk, cancellationToken);
767 case BridgeCommandType.Event:
768 return TriggerCustomEvent(parameters.EventInvocation);
769 case null:
770 return BridgeError("Missing commandType!");
771 default:
772 return BridgeError($"commandType {parameters.CommandType} not supported!");
773 }
774
775 return response;
776 }
777#pragma warning restore CA1502
778
785 {
786 Logger.LogWarning("Bridge request error: {message}", message);
787 return new BridgeResponse
788 {
789 ErrorMessage = message,
790 };
791 }
792
799 async ValueTask<CombinedTopicResponse?> SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
800 {
801 parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
802
803 var fullCommandString = GenerateQueryString(parameters, out var json);
804 if (LogTopicRequests)
805 Logger.LogTrace("Topic request: {json}", json);
806 var fullCommandByteCount = Encoding.UTF8.GetByteCount(fullCommandString);
807 var topicPriority = parameters.IsPriority;
808 if (fullCommandByteCount <= DMApiConstants.MaximumTopicRequestLength)
809 return await SendRawTopic(fullCommandString, topicPriority, cancellationToken);
810
811 var interopChunkingVersion = new Version(5, 6, 0);
812 if (ReattachInformation.Dmb.CompileJob.DMApiVersion < interopChunkingVersion)
813 {
814 Logger.LogWarning(
815 "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}!",
817 fullCommandByteCount,
818 interopChunkingVersion);
819 return null;
820 }
821
822 var payloadId = NextPayloadId;
823
824 // AccessIdentifer is just noise in a chunked request
825 parameters.AccessIdentifier = null!;
826 GenerateQueryString(parameters, out json);
827
828 // yes, this straight up ignores unicode, precalculating it is useless when we don't
829 // even know if the UTF8 bytes of the url encoded chunk will fit the window until we do said encoding
830 var fullPayloadSize = (uint)json.Length;
831
832 List<string>? chunkQueryStrings = null;
833 for (var chunkCount = 2; chunkQueryStrings == null; ++chunkCount)
834 {
835 var standardChunkSize = fullPayloadSize / chunkCount;
836 var bigChunkSize = standardChunkSize + (fullPayloadSize % chunkCount);
837 if (bigChunkSize > DMApiConstants.MaximumTopicRequestLength)
838 continue;
839
840 chunkQueryStrings = new List<string>();
841 for (var i = 0U; i < chunkCount; ++i)
842 {
843 var startIndex = i * standardChunkSize;
844 var subStringLength = Math.Min(
845 fullPayloadSize - startIndex,
846 i == chunkCount - 1
847 ? bigChunkSize
848 : standardChunkSize);
849 var chunkPayload = json.Substring((int)startIndex, (int)subStringLength);
850
851 var chunk = new ChunkData
852 {
853 Payload = chunkPayload,
854 PayloadId = payloadId,
855 SequenceId = i,
856 TotalChunks = (uint)chunkCount,
857 };
858
859 var chunkParameters = new TopicParameters(chunk)
860 {
861 AccessIdentifier = ReattachInformation.AccessIdentifier,
862 };
863
864 var chunkCommandString = GenerateQueryString(chunkParameters, out _);
865 if (Encoding.UTF8.GetByteCount(chunkCommandString) > DMApiConstants.MaximumTopicRequestLength)
866 {
867 // too long when encoded, need more chunks
868 chunkQueryStrings = null;
869 break;
870 }
871
872 chunkQueryStrings.Add(chunkCommandString);
873 }
874 }
875
876 Logger.LogTrace("Chunking topic request ({totalChunks} total)...", chunkQueryStrings.Count);
877
878 CombinedTopicResponse? combinedResponse = null;
879 bool LogRequestIssue(bool possiblyFromCompletedRequest)
880 {
881 if (combinedResponse?.InteropResponse == null || combinedResponse.InteropResponse.ErrorMessage != null)
882 {
883 Logger.LogWarning(
884 "Topic request {chunkingStatus} failed!{potentialRequestError}",
885 possiblyFromCompletedRequest ? "final chunk" : "chunking",
886 combinedResponse?.InteropResponse?.ErrorMessage != null
887 ? $" Request error: {combinedResponse.InteropResponse.ErrorMessage}"
888 : String.Empty);
889 return true;
890 }
891
892 return false;
893 }
894
895 foreach (var chunkCommandString in chunkQueryStrings)
896 {
897 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
898 if (LogRequestIssue(chunkCommandString == chunkQueryStrings.Last()))
899 return null;
900 }
901
902 while ((combinedResponse?.InteropResponse?.MissingChunks?.Count ?? 0) > 0)
903 {
904 Logger.LogWarning("DD is still missing some chunks of topic request P{payloadId}! Sending missing chunks...", payloadId);
905 var missingChunks = combinedResponse!.InteropResponse!.MissingChunks!;
906 var lastIndex = missingChunks.Last();
907 foreach (var missingChunkIndex in missingChunks)
908 {
909 var chunkCommandString = chunkQueryStrings[(int)missingChunkIndex];
910 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
911 if (LogRequestIssue(missingChunkIndex == lastIndex))
912 return null;
913 }
914 }
915
916 return combinedResponse;
917 }
918
925 string GenerateQueryString(TopicParameters parameters, out string json)
926 {
927 json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
928 var commandString = String.Format(
929 CultureInfo.InvariantCulture,
930 "?{0}={1}",
932 byondTopicSender.SanitizeString(json));
933 return commandString;
934 }
935
943 async ValueTask<CombinedTopicResponse?> SendRawTopic(string queryString, bool priority, CancellationToken cancellationToken)
944 {
945 if (disposed)
946 {
947 Logger.LogWarning(
948 "Attempted to send a topic on a disposed SessionController");
949 return null;
950 }
951
952 var targetPort = ReattachInformation.TopicPort ?? ReattachInformation.Port;
953 Byond.TopicSender.TopicResponse? byondResponse;
954 using (await TopicSendSemaphore.Lock(cancellationToken))
955 byondResponse = await byondTopicSender.SendWithOptionalPriority(
957 LogTopicRequests
958 ? Logger
959 : NullLogger.Instance,
960 queryString,
961 targetPort,
962 priority,
963 cancellationToken);
964
965 if (byondResponse == null)
966 {
967 if (priority)
968 Logger.LogError(
969 "Unable to send priority topic \"{queryString}\"!",
970 queryString);
971
972 return null;
973 }
974
975 var topicReturn = byondResponse.StringData;
976
977 TopicResponse? interopResponse = null;
978 if (topicReturn != null)
979 try
980 {
981 interopResponse = JsonConvert.DeserializeObject<TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
982 }
983 catch (Exception ex)
984 {
985 Logger.LogWarning(ex, "Invalid interop response: {topicReturnString}", topicReturn);
986 }
987
988 return new CombinedTopicResponse(byondResponse, interopResponse);
989 }
990
998 async ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, bool bypassLaunchResult, CancellationToken cancellationToken)
999 {
1000 ArgumentNullException.ThrowIfNull(parameters);
1001
1002 if (Lifetime.IsCompleted || disposed)
1003 {
1004 Logger.LogWarning(
1005 "Attempted to send a command to an inactive SessionController: {commandType}",
1006 parameters.CommandType);
1007 return null;
1008 }
1009
1010 if (!DMApiAvailable)
1011 {
1012 Logger.LogTrace("Not sending topic request {commandType} to server without/with incompatible DMAPI!", parameters.CommandType);
1013 return null;
1014 }
1015
1016 var reboot = OnReboot;
1017 if (!bypassLaunchResult)
1018 {
1019 var launchResult = await LaunchResult.WaitAsync(cancellationToken);
1020 if (launchResult.ExitCode.HasValue)
1021 {
1022 Logger.LogDebug("Not sending topic request {commandType} to server that failed to launch!", parameters.CommandType);
1023 return null;
1024 }
1025 }
1026
1027 // meh, this is kind of a hack, but it works
1029 {
1030 Logger.LogDebug("Not sending topic request {commandType} to server that is rebooting/starting.", parameters.CommandType);
1031 return null;
1032 }
1033
1034 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1035 var combinedCancellationToken = cts.Token;
1036 async ValueTask CancelIfLifetimeElapses()
1037 {
1038 try
1039 {
1040 var completed = await Task.WhenAny(Lifetime, reboot).WaitAsync(combinedCancellationToken);
1041
1042 Logger.LogDebug(
1043 "Server {action}, cancelling pending command: {commandType}",
1044 completed != reboot
1045 ? "process ended"
1046 : "rebooting",
1047 parameters.CommandType);
1048 cts.Cancel();
1049 }
1050 catch (OperationCanceledException)
1051 {
1052 // expected, not even worth tracing
1053 }
1054 catch (Exception ex)
1055 {
1056 Logger.LogError(ex, "Error in CancelIfLifetimeElapses!");
1057 }
1058 }
1059
1060 TopicResponse? fullResponse = null;
1061 var lifetimeWatchingTask = CancelIfLifetimeElapses();
1062 try
1063 {
1064 var combinedResponse = await SendTopicRequest(parameters, combinedCancellationToken);
1065
1066 void LogCombinedResponse()
1067 {
1068 if (LogTopicRequests && combinedResponse != null)
1069 Logger.LogTrace("Topic response: {topicString}", combinedResponse.ByondTopicResponse.StringData ?? "(NO STRING DATA)");
1070 }
1071
1072 LogCombinedResponse();
1073
1074 if (combinedResponse?.InteropResponse?.Chunk != null)
1075 {
1076 Logger.LogTrace("Topic response is chunked...");
1077
1078 ChunkData? nextChunk = combinedResponse.InteropResponse.Chunk;
1079 do
1080 {
1081 var nextRequest = await ProcessChunk<TopicResponse, ChunkedTopicParameters>(
1082 (completedResponse, _) =>
1083 {
1084 fullResponse = completedResponse;
1085 return ValueTask.FromResult<ChunkedTopicParameters?>(null);
1086 },
1087 error =>
1088 {
1089 Logger.LogWarning("Topic response chunking error: {message}", error);
1090 return null;
1091 },
1092 combinedResponse?.InteropResponse?.Chunk,
1093 combinedCancellationToken);
1094
1095 if (nextRequest != null)
1096 {
1097 nextRequest.PayloadId = nextChunk.PayloadId;
1098 combinedResponse = await SendTopicRequest(nextRequest, combinedCancellationToken);
1099 LogCombinedResponse();
1100 nextChunk = combinedResponse?.InteropResponse?.Chunk;
1101 }
1102 else
1103 nextChunk = null;
1104 }
1105 while (nextChunk != null);
1106 }
1107 else
1108 fullResponse = combinedResponse?.InteropResponse;
1109 }
1110 catch (OperationCanceledException ex)
1111 {
1112 Logger.LogDebug(
1113 ex,
1114 "Topic request {cancellationType}!",
1115 combinedCancellationToken.IsCancellationRequested
1116 ? cancellationToken.IsCancellationRequested
1117 ? "cancelled"
1118 : "aborted"
1119 : "timed out");
1120
1121 // throw only if the original token was the trigger
1122 cancellationToken.ThrowIfCancellationRequested();
1123 }
1124 finally
1125 {
1126 cts.Cancel();
1127 await lifetimeWatchingTask;
1128 }
1129
1130 if (fullResponse?.ErrorMessage != null)
1131 Logger.LogWarning(
1132 "Errored topic response for command {commandType}: {errorMessage}",
1133 parameters.CommandType,
1134 fullResponse.ErrorMessage);
1135
1136 return fullResponse;
1137 }
1138
1145 {
1146 if (invocation == null)
1147 return BridgeError("Missing eventInvocation!");
1148
1149 var eventName = invocation.EventName;
1150 if (eventName == null)
1151 return BridgeError("Missing eventName!");
1152
1153 var notifyCompletion = invocation.NotifyCompletion;
1154 if (!notifyCompletion.HasValue)
1155 return BridgeError("Missing notifyCompletion!");
1156
1157 var eventParams = new List<string>
1158 {
1160 };
1161
1162 eventParams.AddRange(invocation
1163 .Parameters?
1164 .Where(param => param != null)
1165 .Cast<string>()
1166 ?? Enumerable.Empty<string>());
1167
1168 var eventId = Guid.NewGuid();
1169 Logger.LogInformation("Triggering custom event \"{eventName}\": {eventId}", eventName, eventId);
1170
1171 var cancellationToken = sessionDurationCts.Token;
1172 ValueTask? eventTask = eventConsumer.HandleCustomEvent(eventName, eventParams, cancellationToken);
1173
1174 async Task ProcessEvent()
1175 {
1176 try
1177 {
1178 Exception? exception;
1179 try
1180 {
1181 await eventTask.Value;
1182 exception = null;
1183 }
1184 catch (Exception ex)
1185 {
1186 exception = ex;
1187 }
1188
1189 if (notifyCompletion.Value)
1190 await SendCommand(
1191 new TopicParameters(eventId),
1192 cancellationToken);
1193 else if (exception == null)
1194 Logger.LogTrace("Finished custom event {eventId}, not sending notification.", eventId);
1195
1196 if (exception != null)
1197 throw exception;
1198 }
1199 catch (OperationCanceledException ex)
1200 {
1201 Logger.LogDebug(ex, "Custom event invocation {eventId} aborted!", eventId);
1202 }
1203 catch (Exception ex)
1204 {
1205 Logger.LogWarning(ex, "Custom event invocation {eventId} errored!", eventId);
1206 }
1207 }
1208
1209 if (!eventTask.HasValue)
1210 return BridgeError("Event refused to execute due to matching a TGS event!");
1211
1212 lock (sessionDurationCts)
1213 {
1214 var previousEventProcessingTask = customEventProcessingTask;
1215 var eventProcessingTask = ProcessEvent();
1216 customEventProcessingTask = Task.WhenAll(customEventProcessingTask, eventProcessingTask);
1217 }
1218
1219 return new BridgeResponse
1220 {
1221 EventId = notifyCompletion.Value
1222 ? eventId.ToString()
1223 : null,
1224 };
1225 }
1226 }
1227}
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.
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.