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