tgstation-server 6.8.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
127 readonly Byond.TopicSender.ITopicClient byondTopicSender;
128
133
138
143
148
153
158
163
168
172 readonly TaskCompletionSource initialBridgeRequestTcs;
173
178
182 readonly CancellationTokenSource sessionDurationCts;
183
187 readonly object synchronizationLock;
188
192 readonly bool apiValidationSession;
193
197 volatile TaskCompletionSource startupTcs;
198
202 volatile TaskCompletionSource rebootTcs;
203
207 volatile TaskCompletionSource primeTcs;
208
212 volatile Task rebootGate;
213
218
223
228
233
238
243
248
270 ReattachInformation reattachInformation,
271 Api.Models.Instance metadata,
274 Byond.TopicSender.ITopicClient byondTopicSender,
276 IBridgeRegistrar bridgeRegistrar,
278 IAssemblyInformationProvider assemblyInformationProvider,
282 ILogger<SessionController> logger,
283 Func<ValueTask> postLifetimeCallback,
284 uint? startupTimeout,
285 bool reattached,
286 bool apiValidate)
287 : base(logger)
288 {
289 ReattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
290 this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
291 this.process = process ?? throw new ArgumentNullException(nameof(process));
292 this.engineLock = engineLock ?? throw new ArgumentNullException(nameof(engineLock));
293 this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
294 this.chatTrackingContext = chatTrackingContext ?? throw new ArgumentNullException(nameof(chatTrackingContext));
295 ArgumentNullException.ThrowIfNull(bridgeRegistrar);
296
297 this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
298 ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
299
300 this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
301 this.dotnetDumpService = dotnetDumpService ?? throw new ArgumentNullException(nameof(dotnetDumpService));
302 this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
303
304 apiValidationSession = apiValidate;
305
306 disposed = false;
308 released = false;
309
310 startupTcs = new TaskCompletionSource();
311 rebootTcs = new TaskCompletionSource();
312 primeTcs = new TaskCompletionSource();
313
314 rebootGate = Task.CompletedTask;
315 customEventProcessingTask = Task.CompletedTask;
316
317 // Run this asynchronously because we want to try to avoid any effects sending topics to the server while the initial bridge request is processing
318 // It MAY be the source of a DD crash. See this gist https://gist.github.com/Cyberboss/7776bbeff3a957d76affe0eae95c9f14
319 // Worth further investigation as to if that sequence of events is a reliable crash vector and opening a BYOND bug if it is
320 initialBridgeRequestTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
321 sessionDurationCts = new CancellationTokenSource();
322
324 synchronizationLock = new object();
325
327 {
328 bridgeRegistration = bridgeRegistrar.RegisterHandler(this);
329 this.chatTrackingContext.SetChannelSink(this);
330 }
331 else
332 logger.LogTrace(
333 "Not registering session with {reasonWhyDmApiIsBad} DMAPI version for interop!",
334 reattachInformation.Dmb.CompileJob.DMApiVersion == null
335 ? "no"
336 : $"incompatible ({reattachInformation.Dmb.CompileJob.DMApiVersion})");
337
338 async Task<int?> WrapLifetime()
339 {
340 var exitCode = await process.Lifetime;
341 await postLifetimeCallback();
342 if (postValidationShutdownTask != null)
344
345 return exitCode;
346 }
347
348 Lifetime = WrapLifetime();
349
351 assemblyInformationProvider,
353 startupTimeout,
354 reattached,
355 apiValidate);
356
357 logger.LogDebug(
358 "Created session controller. CommsKey: {accessIdentifier}, Port: {port}",
359 reattachInformation.AccessIdentifier,
360 reattachInformation.Port);
361 }
362
364 public async ValueTask DisposeAsync()
365 {
367 {
368 if (disposed)
369 return;
370 disposed = true;
371 }
372
373 Logger.LogTrace("Disposing...");
374
375 sessionDurationCts.Cancel();
376 var cancellationToken = CancellationToken.None; // DCT: None available
377 var semaphoreLockTask = TopicSendSemaphore.Lock(cancellationToken);
378
379 if (!released)
380 {
382 Logger,
383 process,
386 cancellationToken);
387 }
388
389 await process.DisposeAsync();
390 engineLock.Dispose();
391 bridgeRegistration?.Dispose();
392 var regularDmbDisposeTask = ReattachInformation.Dmb.DisposeAsync();
393 var initialDmb = ReattachInformation.InitialDmb;
394 if (initialDmb != null)
395 await initialDmb.DisposeAsync();
396
397 await regularDmbDisposeTask;
398
399 chatTrackingContext.Dispose();
400 sessionDurationCts.Dispose();
401
402 if (!released)
403 await Lifetime; // finish the async callback
404
405 (await semaphoreLockTask).Dispose();
407
409 }
410
412 public async ValueTask<BridgeResponse?> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
413 {
414 ArgumentNullException.ThrowIfNull(parameters);
415
416 using (LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, metadata.Id))
417 {
418 Logger.LogTrace("Handling bridge request...");
419
420 try
421 {
422 return await ProcessBridgeCommand(parameters, cancellationToken);
423 }
424 finally
425 {
426 initialBridgeRequestTcs.TrySetResult();
427 }
428 }
429 }
430
432 public ValueTask Release()
433 {
435
439 released = true;
440 return DisposeAsync();
441 }
442
444 public ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
445 => SendCommand(parameters, false, cancellationToken);
446
448 public async ValueTask<bool> SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
449 {
450 if (RebootState == newRebootState)
451 return true;
452
453 Logger.LogTrace("Changing reboot state to {newRebootState}", newRebootState);
454
455 ReattachInformation.RebootState = newRebootState;
456 var result = await SendCommand(
457 new TopicParameters(newRebootState),
458 cancellationToken);
459
460 return result != null && result.ErrorMessage == null;
461 }
462
464 public void ResetRebootState()
465 {
467 Logger.LogTrace("Resetting reboot state...");
468 ReattachInformation.RebootState = RebootState.Normal;
469 }
470
472 public void AdjustPriority(bool higher) => process.AdjustPriority(higher);
473
476
479
482 {
483 var oldDmb = ReattachInformation.Dmb;
484 ReattachInformation.Dmb = dmbProvider ?? throw new ArgumentNullException(nameof(dmbProvider));
485 return oldDmb;
486 }
487
489 public async ValueTask InstanceRenamed(string newInstanceName, CancellationToken cancellationToken)
490 {
491 var runtimeInformation = ReattachInformation.RuntimeInformation;
492 if (runtimeInformation != null)
493 runtimeInformation.InstanceName = newInstanceName;
494
495 await SendCommand(
497 cancellationToken);
498 }
499
501 public async ValueTask UpdateChannels(IEnumerable<ChannelRepresentation> newChannels, CancellationToken cancellationToken)
502 => await SendCommand(
503 new TopicParameters(
504 new ChatUpdate(newChannels)),
505 cancellationToken);
506
508 public ValueTask CreateDump(string outputFile, bool minidump, CancellationToken cancellationToken)
509 {
511 return dotnetDumpService.Dump(process, outputFile, minidump, cancellationToken);
512
513 return process.CreateDump(outputFile, minidump, cancellationToken);
514 }
515
525 async Task<LaunchResult> GetLaunchResult(
526 IAssemblyInformationProvider assemblyInformationProvider,
528 uint? startupTimeout,
529 bool reattached,
530 bool apiValidate)
531 {
532 var startTime = DateTimeOffset.UtcNow;
533 var useBridgeRequestForLaunchResult = !reattached && (apiValidate || DMApiAvailable);
534 var startupTask = useBridgeRequestForLaunchResult
535 ? initialBridgeRequestTcs.Task
537 var toAwait = Task.WhenAny(startupTask, process.Lifetime);
538
539 if (startupTimeout.HasValue)
540 toAwait = Task.WhenAny(
541 toAwait,
543 TimeSpan.FromSeconds(startupTimeout.Value),
544 CancellationToken.None)); // DCT: None available, task will clean up after delay
545
546 Logger.LogTrace(
547 "Waiting for LaunchResult based on {launchResultCompletionCause}{possibleTimeout}...",
548 useBridgeRequestForLaunchResult ? "initial bridge request" : "process startup",
549 startupTimeout.HasValue ? $" with a timeout of {startupTimeout.Value}s" : String.Empty);
550
551 await toAwait;
552
553 var result = new LaunchResult
554 {
555 ExitCode = process.Lifetime.IsCompleted ? await process.Lifetime : null,
556 StartupTime = startupTask.IsCompleted ? (DateTimeOffset.UtcNow - startTime) : null,
557 };
558
559 Logger.LogTrace("Launch result: {launchResult}", result);
560
561 if (!result.ExitCode.HasValue && reattached && !disposed)
562 {
563 var reattachResponse = await SendCommand(
564 new TopicParameters(
565 assemblyInformationProvider.Version,
567 true,
568 sessionDurationCts.Token);
569
570 if (reattachResponse != null)
571 {
572 if (reattachResponse?.CustomCommands != null)
573 chatTrackingContext.CustomCommands = reattachResponse.CustomCommands;
574 else if (reattachResponse != null)
575 Logger.Log(
576 CompileJob.DMApiVersion >= new Version(5, 2, 0)
577 ? LogLevel.Warning
578 : LogLevel.Debug,
579 "DMAPI Interop v{interopVersion} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
580 CompileJob.DMApiVersion!.Semver());
581 }
582 }
583
584 return result;
585 }
586
590 void CheckDisposed() => ObjectDisposedException.ThrowIf(disposed, this);
591
597 async Task PostValidationShutdown(Task<bool> proceedTask)
598 {
599 Logger.LogTrace("Entered post validation terminate task.");
600 if (!await proceedTask)
601 {
602 Logger.LogTrace("Not running post validation terminate task for repeated bridge request.");
603 return;
604 }
605
606 const int GracePeriodSeconds = 30;
607 Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds);
608 var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None); // DCT: None available
609 await Task.WhenAny(process.Lifetime, delayTask);
610
611 if (!process.Lifetime.IsCompleted)
612 {
613 Logger.LogWarning("DMAPI took too long to shutdown server after validation request!");
615 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
616 }
617 else
618 Logger.LogTrace("Server exited properly post validation.");
619 }
620
627#pragma warning disable CA1502 // TODO: Decomplexify
628 async ValueTask<BridgeResponse?> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
629 {
630 var response = new BridgeResponse();
631 switch (parameters.CommandType)
632 {
633 case BridgeCommandType.ChatSend:
634 if (parameters.ChatMessage == null)
635 return BridgeError("Missing chatMessage field!");
636
637 if (parameters.ChatMessage.ChannelIds == null)
638 return BridgeError("Missing channelIds field in chatMessage!");
639
640 if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
641 return BridgeError("Invalid channelIds in chatMessage!");
642
643 if (parameters.ChatMessage.Text == null)
644 return BridgeError("Missing message field in chatMessage!");
645
646 var anyFailed = false;
647 var parsedChannels = parameters.ChatMessage.ChannelIds.Select(
648 channelString =>
649 {
650 anyFailed |= !UInt64.TryParse(channelString, out var channelId);
651 return channelId;
652 });
653
654 if (anyFailed)
655 return BridgeError("Failed to parse channelIds as U64!");
656
658 parameters.ChatMessage,
659 parsedChannels);
660 break;
661 case BridgeCommandType.Prime:
662 Interlocked.Exchange(ref primeTcs, new TaskCompletionSource()).SetResult();
663 break;
664 case BridgeCommandType.Kill:
665 Logger.LogInformation("Bridge requested process termination!");
666 chatTrackingContext.Active = false;
669 break;
670 case BridgeCommandType.DeprecatedPortUpdate:
671 return BridgeError("Port switching is no longer supported!");
672 case BridgeCommandType.Startup:
673 apiValidationStatus = ApiValidationStatus.BadValidationRequest;
674
676 {
677 var proceedTcs = new TaskCompletionSource<bool>();
678 var firstValidationRequest = Interlocked.CompareExchange(ref postValidationShutdownTask, PostValidationShutdown(proceedTcs.Task), null) == null;
679 proceedTcs.SetResult(firstValidationRequest);
680
681 if (!firstValidationRequest)
682 return BridgeError("Startup bridge request was repeated!");
683 }
684
685 if (parameters.Version == null)
686 return BridgeError("Missing dmApiVersion field!");
687
688 DMApiVersion = parameters.Version;
689
690 // TODO: When OD figures out how to unite port and topic_port, set an upper version bound on OD for this check
692 || (EngineVersion.Engine == EngineType.OpenDream && DMApiVersion < new Version(5, 7)))
693 {
695 return BridgeError("Incompatible dmApiVersion!");
696 }
697
698 switch (parameters.MinimumSecurityLevel)
699 {
700 case DreamDaemonSecurity.Ultrasafe:
701 apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
702 break;
703 case DreamDaemonSecurity.Safe:
705 break;
706 case DreamDaemonSecurity.Trusted:
708 break;
709 case null:
710 return BridgeError("Missing minimumSecurityLevel field!");
711 default:
712 return BridgeError("Invalid minimumSecurityLevel!");
713 }
714
715 Logger.LogTrace("ApiValidationStatus set to {apiValidationStatus}", apiValidationStatus);
716
717 // we create new runtime info here because of potential .Dmb changes (i think. i forget...)
718 response.RuntimeInformation = new RuntimeInformation(
727
728 if (parameters.TopicPort.HasValue)
729 {
730 var newTopicPort = parameters.TopicPort.Value;
731 Logger.LogInformation("Server is requesting use of port {topicPort} for topic communications", newTopicPort);
732 ReattachInformation.TopicPort = newTopicPort;
733 }
734
735 // Load custom commands
736 chatTrackingContext.CustomCommands = parameters.CustomCommands ?? Array.Empty<CustomCommand>();
737 chatTrackingContext.Active = true;
738 Interlocked.Exchange(ref startupTcs, new TaskCompletionSource()).SetResult();
739 break;
740 case BridgeCommandType.Reboot:
741 Interlocked.Increment(ref rebootBridgeRequestsProcessing);
742 try
743 {
744 chatTrackingContext.Active = false;
745 Interlocked.Exchange(ref rebootTcs, new TaskCompletionSource()).SetResult();
746 await RebootGate.WaitAsync(cancellationToken);
747 }
748 finally
749 {
750 Interlocked.Decrement(ref rebootBridgeRequestsProcessing);
751 }
752
753 break;
754 case BridgeCommandType.Chunk:
755 return await ProcessChunk<BridgeParameters, BridgeResponse>(ProcessBridgeCommand, BridgeError, parameters.Chunk, cancellationToken);
756 case BridgeCommandType.Event:
757 return TriggerCustomEvent(parameters.EventInvocation);
758 case null:
759 return BridgeError("Missing commandType!");
760 default:
761 return BridgeError($"commandType {parameters.CommandType} not supported!");
762 }
763
764 return response;
765 }
766#pragma warning restore CA1502
767
774 {
775 Logger.LogWarning("Bridge request error: {message}", message);
776 return new BridgeResponse
777 {
778 ErrorMessage = message,
779 };
780 }
781
788 async ValueTask<CombinedTopicResponse?> SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
789 {
790 parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
791
792 var fullCommandString = GenerateQueryString(parameters, out var json);
793 if (LogTopicRequests)
794 Logger.LogTrace("Topic request: {json}", json);
795 var fullCommandByteCount = Encoding.UTF8.GetByteCount(fullCommandString);
796 var topicPriority = parameters.IsPriority;
797 if (fullCommandByteCount <= DMApiConstants.MaximumTopicRequestLength)
798 return await SendRawTopic(fullCommandString, topicPriority, cancellationToken);
799
800 var interopChunkingVersion = new Version(5, 6, 0);
801 if (ReattachInformation.Dmb.CompileJob.DMApiVersion < interopChunkingVersion)
802 {
803 Logger.LogWarning(
804 "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}!",
806 fullCommandByteCount,
807 interopChunkingVersion);
808 return null;
809 }
810
811 var payloadId = NextPayloadId;
812
813 // AccessIdentifer is just noise in a chunked request
814 parameters.AccessIdentifier = null!;
815 GenerateQueryString(parameters, out json);
816
817 // yes, this straight up ignores unicode, precalculating it is useless when we don't
818 // even know if the UTF8 bytes of the url encoded chunk will fit the window until we do said encoding
819 var fullPayloadSize = (uint)json.Length;
820
821 List<string>? chunkQueryStrings = null;
822 for (var chunkCount = 2; chunkQueryStrings == null; ++chunkCount)
823 {
824 var standardChunkSize = fullPayloadSize / chunkCount;
825 var bigChunkSize = standardChunkSize + (fullPayloadSize % chunkCount);
826 if (bigChunkSize > DMApiConstants.MaximumTopicRequestLength)
827 continue;
828
829 chunkQueryStrings = new List<string>();
830 for (var i = 0U; i < chunkCount; ++i)
831 {
832 var startIndex = i * standardChunkSize;
833 var subStringLength = Math.Min(
834 fullPayloadSize - startIndex,
835 i == chunkCount - 1
836 ? bigChunkSize
837 : standardChunkSize);
838 var chunkPayload = json.Substring((int)startIndex, (int)subStringLength);
839
840 var chunk = new ChunkData
841 {
842 Payload = chunkPayload,
843 PayloadId = payloadId,
844 SequenceId = i,
845 TotalChunks = (uint)chunkCount,
846 };
847
848 var chunkParameters = new TopicParameters(chunk)
849 {
850 AccessIdentifier = ReattachInformation.AccessIdentifier,
851 };
852
853 var chunkCommandString = GenerateQueryString(chunkParameters, out _);
854 if (Encoding.UTF8.GetByteCount(chunkCommandString) > DMApiConstants.MaximumTopicRequestLength)
855 {
856 // too long when encoded, need more chunks
857 chunkQueryStrings = null;
858 break;
859 }
860
861 chunkQueryStrings.Add(chunkCommandString);
862 }
863 }
864
865 Logger.LogTrace("Chunking topic request ({totalChunks} total)...", chunkQueryStrings.Count);
866
867 CombinedTopicResponse? combinedResponse = null;
868 bool LogRequestIssue(bool possiblyFromCompletedRequest)
869 {
870 if (combinedResponse?.InteropResponse == null || combinedResponse.InteropResponse.ErrorMessage != null)
871 {
872 Logger.LogWarning(
873 "Topic request {chunkingStatus} failed!{potentialRequestError}",
874 possiblyFromCompletedRequest ? "final chunk" : "chunking",
875 combinedResponse?.InteropResponse?.ErrorMessage != null
876 ? $" Request error: {combinedResponse.InteropResponse.ErrorMessage}"
877 : String.Empty);
878 return true;
879 }
880
881 return false;
882 }
883
884 foreach (var chunkCommandString in chunkQueryStrings)
885 {
886 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
887 if (LogRequestIssue(chunkCommandString == chunkQueryStrings.Last()))
888 return null;
889 }
890
891 while ((combinedResponse?.InteropResponse?.MissingChunks?.Count ?? 0) > 0)
892 {
893 Logger.LogWarning("DD is still missing some chunks of topic request P{payloadId}! Sending missing chunks...", payloadId);
894 var missingChunks = combinedResponse!.InteropResponse!.MissingChunks!;
895 var lastIndex = missingChunks.Last();
896 foreach (var missingChunkIndex in missingChunks)
897 {
898 var chunkCommandString = chunkQueryStrings[(int)missingChunkIndex];
899 combinedResponse = await SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
900 if (LogRequestIssue(missingChunkIndex == lastIndex))
901 return null;
902 }
903 }
904
905 return combinedResponse;
906 }
907
914 string GenerateQueryString(TopicParameters parameters, out string json)
915 {
916 json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
917 var commandString = String.Format(
918 CultureInfo.InvariantCulture,
919 "?{0}={1}",
921 byondTopicSender.SanitizeString(json));
922 return commandString;
923 }
924
932 async ValueTask<CombinedTopicResponse?> SendRawTopic(string queryString, bool priority, CancellationToken cancellationToken)
933 {
934 if (disposed)
935 {
936 Logger.LogWarning(
937 "Attempted to send a topic on a disposed SessionController");
938 return null;
939 }
940
941 var targetPort = ReattachInformation.TopicPort ?? ReattachInformation.Port;
942 Byond.TopicSender.TopicResponse? byondResponse;
943 using (await TopicSendSemaphore.Lock(cancellationToken))
944 byondResponse = await byondTopicSender.SendWithOptionalPriority(
946 LogTopicRequests
947 ? Logger
948 : NullLogger.Instance,
949 queryString,
950 targetPort,
951 priority,
952 cancellationToken);
953
954 if (byondResponse == null)
955 {
956 if (priority)
957 Logger.LogError(
958 "Unable to send priority topic \"{queryString}\"!",
959 queryString);
960
961 return null;
962 }
963
964 var topicReturn = byondResponse.StringData;
965
966 TopicResponse? interopResponse = null;
967 if (topicReturn != null)
968 try
969 {
970 interopResponse = JsonConvert.DeserializeObject<TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
971 }
972 catch (Exception ex)
973 {
974 Logger.LogWarning(ex, "Invalid interop response: {topicReturnString}", topicReturn);
975 }
976
977 return new CombinedTopicResponse(byondResponse, interopResponse);
978 }
979
987 async ValueTask<TopicResponse?> SendCommand(TopicParameters parameters, bool bypassLaunchResult, CancellationToken cancellationToken)
988 {
989 ArgumentNullException.ThrowIfNull(parameters);
990
991 if (Lifetime.IsCompleted || disposed)
992 {
993 Logger.LogWarning(
994 "Attempted to send a command to an inactive SessionController: {commandType}",
995 parameters.CommandType);
996 return null;
997 }
998
999 if (!DMApiAvailable)
1000 {
1001 Logger.LogTrace("Not sending topic request {commandType} to server without/with incompatible DMAPI!", parameters.CommandType);
1002 return null;
1003 }
1004
1005 var reboot = OnReboot;
1006 if (!bypassLaunchResult)
1007 {
1008 var launchResult = await LaunchResult.WaitAsync(cancellationToken);
1009 if (launchResult.ExitCode.HasValue)
1010 {
1011 Logger.LogDebug("Not sending topic request {commandType} to server that failed to launch!", parameters.CommandType);
1012 return null;
1013 }
1014 }
1015
1016 // meh, this is kind of a hack, but it works
1018 {
1019 Logger.LogDebug("Not sending topic request {commandType} to server that is rebooting/starting.", parameters.CommandType);
1020 return null;
1021 }
1022
1023 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1024 var combinedCancellationToken = cts.Token;
1025 async ValueTask CancelIfLifetimeElapses()
1026 {
1027 try
1028 {
1029 var completed = await Task.WhenAny(Lifetime, reboot).WaitAsync(combinedCancellationToken);
1030
1031 Logger.LogDebug(
1032 "Server {action}, cancelling pending command: {commandType}",
1033 completed != reboot
1034 ? "process ended"
1035 : "rebooting",
1036 parameters.CommandType);
1037 cts.Cancel();
1038 }
1039 catch (OperationCanceledException)
1040 {
1041 // expected, not even worth tracing
1042 }
1043 catch (Exception ex)
1044 {
1045 Logger.LogError(ex, "Error in CancelIfLifetimeElapses!");
1046 }
1047 }
1048
1049 TopicResponse? fullResponse = null;
1050 var lifetimeWatchingTask = CancelIfLifetimeElapses();
1051 try
1052 {
1053 var combinedResponse = await SendTopicRequest(parameters, combinedCancellationToken);
1054
1055 void LogCombinedResponse()
1056 {
1057 if (LogTopicRequests && combinedResponse != null)
1058 Logger.LogTrace("Topic response: {topicString}", combinedResponse.ByondTopicResponse.StringData ?? "(NO STRING DATA)");
1059 }
1060
1061 LogCombinedResponse();
1062
1063 if (combinedResponse?.InteropResponse?.Chunk != null)
1064 {
1065 Logger.LogTrace("Topic response is chunked...");
1066
1067 ChunkData? nextChunk = combinedResponse.InteropResponse.Chunk;
1068 do
1069 {
1070 var nextRequest = await ProcessChunk<TopicResponse, ChunkedTopicParameters>(
1071 (completedResponse, _) =>
1072 {
1073 fullResponse = completedResponse;
1074 return ValueTask.FromResult<ChunkedTopicParameters?>(null);
1075 },
1076 error =>
1077 {
1078 Logger.LogWarning("Topic response chunking error: {message}", error);
1079 return null;
1080 },
1081 combinedResponse?.InteropResponse?.Chunk,
1082 combinedCancellationToken);
1083
1084 if (nextRequest != null)
1085 {
1086 nextRequest.PayloadId = nextChunk.PayloadId;
1087 combinedResponse = await SendTopicRequest(nextRequest, combinedCancellationToken);
1088 LogCombinedResponse();
1089 nextChunk = combinedResponse?.InteropResponse?.Chunk;
1090 }
1091 else
1092 nextChunk = null;
1093 }
1094 while (nextChunk != null);
1095 }
1096 else
1097 fullResponse = combinedResponse?.InteropResponse;
1098 }
1099 catch (OperationCanceledException ex)
1100 {
1101 Logger.LogDebug(
1102 ex,
1103 "Topic request {cancellationType}!",
1104 combinedCancellationToken.IsCancellationRequested
1105 ? cancellationToken.IsCancellationRequested
1106 ? "cancelled"
1107 : "aborted"
1108 : "timed out");
1109
1110 // throw only if the original token was the trigger
1111 cancellationToken.ThrowIfCancellationRequested();
1112 }
1113 finally
1114 {
1115 cts.Cancel();
1116 await lifetimeWatchingTask;
1117 }
1118
1119 if (fullResponse?.ErrorMessage != null)
1120 Logger.LogWarning(
1121 "Errored topic response for command {commandType}: {errorMessage}",
1122 parameters.CommandType,
1123 fullResponse.ErrorMessage);
1124
1125 return fullResponse;
1126 }
1127
1134 {
1135 if (invocation == null)
1136 return BridgeError("Missing eventInvocation!");
1137
1138 var eventName = invocation.EventName;
1139 if (eventName == null)
1140 return BridgeError("Missing eventName!");
1141
1142 var notifyCompletion = invocation.NotifyCompletion;
1143 if (!notifyCompletion.HasValue)
1144 return BridgeError("Missing notifyCompletion!");
1145
1146 var eventParams = new List<string>
1147 {
1149 };
1150
1151 eventParams.AddRange(invocation
1152 .Parameters?
1153 .Where(param => param != null)
1154 .Cast<string>()
1155 ?? Enumerable.Empty<string>());
1156
1157 var eventId = Guid.NewGuid();
1158 Logger.LogInformation("Triggering custom event \"{eventName}\": {eventId}", eventName, eventId);
1159
1160 var cancellationToken = sessionDurationCts.Token;
1161 ValueTask? eventTask = eventConsumer.HandleCustomEvent(eventName, eventParams, cancellationToken);
1162
1163 async Task ProcessEvent()
1164 {
1165 try
1166 {
1167 await eventTask.Value;
1168
1169 if (notifyCompletion.Value)
1170 await SendCommand(
1171 new TopicParameters(eventId),
1172 cancellationToken);
1173 else
1174 Logger.LogTrace("Finished custom event {eventId}, not sending notification.", eventId);
1175 }
1176 catch (OperationCanceledException ex)
1177 {
1178 Logger.LogDebug(ex, "Custom event invocation {eventId} aborted!", eventId);
1179 }
1180 catch (Exception ex)
1181 {
1182 Logger.LogWarning(ex, "Custom event invocation {eventId} errored!", eventId);
1183 }
1184 }
1185
1186 if (!eventTask.HasValue)
1187 return BridgeError("Event refused to execute due to matching a TGS event!");
1188
1189 lock (sessionDurationCts)
1190 {
1191 var previousEventProcessingTask = customEventProcessingTask;
1192 var eventProcessingTask = ProcessEvent();
1193 customEventProcessingTask = Task.WhenAll(customEventProcessingTask, eventProcessingTask);
1194 }
1195
1196 return new BridgeResponse
1197 {
1198 EventId = notifyCompletion.Value
1199 ? eventId.ToString()
1200 : null,
1201 };
1202 }
1203 }
1204}
Information about an engine installation.
EngineType? Engine
The EngineType.
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.
ushort ServerPort
The port the HTTP server is running on.
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....
Definition: ChatMessage.cs:13
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 om...
Definition: ChunkSetInfo.cs:9
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.
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.
Definition: LaunchResult.cs:10
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 TopicRes...
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.
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 requ...
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.
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 representin...
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.
Definition: IChannelSink.cs:11
For managing connected chat services.
Definition: IChatManager.cs:15
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.
Definition: IDmbProvider.cs:11
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.
Definition: IDmbProvider.cs:30
Models.CompileJob CompileJob
The CompileJob of the .dmb.
Definition: IDmbProvider.cs:25
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.
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.
Definition: IProcessBase.cs:14
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.
Task 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
BridgeCommandType
Represents the BridgeParameters.CommandType.
RebootState
Represents the action to take when /world/Reboot() is called.
Definition: RebootState.cs:7
ApiValidationStatus
Status of DMAPI validation.