2using System.Collections.Generic;
3using System.Globalization;
8using System.Threading.Tasks;
10using Microsoft.Extensions.Logging;
36 internal static bool LogTopicRequests {
get;
set; } =
true;
47 throw new InvalidOperationException(
"ApiValidated cannot be checked while Lifetime is incomplete!");
85 var tcs =
new TaskCompletionSource();
94 toAwait = Interlocked.Exchange(ref
rebootGate, Wrap());
252 ILogger<SessionController> logger,
253 Func<Task> postLifetimeCallback,
254 uint? startupTimeout,
259 ReattachInformation = reattachInformation ??
throw new ArgumentNullException(nameof(reattachInformation));
260 this.metadata =
metadata ??
throw new ArgumentNullException(nameof(
metadata));
261 this.process =
process ??
throw new ArgumentNullException(nameof(
process));
265 ArgumentNullException.ThrowIfNull(bridgeRegistrar);
267 this.chat =
chat ??
throw new ArgumentNullException(nameof(
chat));
268 ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
279 primeTcs =
new TaskCompletionSource();
297 "Not registering session with {reasonWhyDmApiIsBad} DMAPI version for interop!",
300 : $
"incompatible ({reattachInformation.Dmb.CompileJob.DMApiVersion})");
302 async Task<int> WrapLifetime()
305 await postLifetimeCallback();
312 assemblyInformationProvider,
319 "Created session controller. CommsKey: {accessIdentifier}, Port: {port}",
321 reattachInformation.
Port);
334 Logger.LogTrace(
"Disposing...");
356 ArgumentNullException.ThrowIfNull(parameters);
360 Logger.LogTrace(
"Handling bridge request...");
391 ArgumentNullException.ThrowIfNull(parameters);
396 "Attempted to send a command to an inactive SessionController: {commandType}",
403 Logger.LogTrace(
"Not sending topic request {commandType} to server without/with incompatible DMAPI!", parameters.
CommandType);
410 var combinedResponse = await
SendTopicRequest(parameters, cancellationToken);
412 void LogCombinedResponse()
414 if (LogTopicRequests && combinedResponse !=
null)
415 Logger.LogTrace(
"Topic response: {topicString}", combinedResponse.ByondTopicResponse.StringData ??
"(NO STRING DATA)");
418 LogCombinedResponse();
420 if (combinedResponse?.InteropResponse?.Chunk !=
null)
422 Logger.LogTrace(
"Topic response is chunked...");
424 ChunkData nextChunk = combinedResponse.InteropResponse.Chunk;
427 var nextRequest = await ProcessChunk<TopicResponse, ChunkedTopicParameters>(
428 (completedResponse, cancellationToken) =>
430 fullResponse = completedResponse;
435 Logger.LogWarning(
"Topic response chunking error: {message}", error);
438 combinedResponse?.InteropResponse?.Chunk,
441 if (nextRequest !=
null)
443 nextRequest.PayloadId = nextChunk.
PayloadId;
445 LogCombinedResponse();
446 nextChunk = combinedResponse?.InteropResponse?.Chunk;
451 while (nextChunk !=
null);
454 fullResponse = combinedResponse?.InteropResponse;
456 catch (OperationCanceledException ex)
460 "Topic request {cancellationType}!",
461 cancellationToken.IsCancellationRequested
464 cancellationToken.ThrowIfCancellationRequested();
467 if (fullResponse?.ErrorMessage !=
null)
469 "Errored topic response for command {commandType}: {errorMessage}",
477 public Task<bool>
SetPort(ushort port, CancellationToken cancellationToken)
482 throw new ArgumentOutOfRangeException(nameof(port), port,
"port must not be zero!");
484 async Task<bool> ImmediateTopicPortChange()
490 if (commandResult?.ErrorMessage !=
null)
493 ReattachInformation.Port = port;
501 throw new InvalidOperationException(
"A port change operation is already in progress!");
507 return ImmediateTopicPortChange();
516 Logger.LogTrace(
"Changing reboot state to {newRebootState}", newRebootState);
518 ReattachInformation.RebootState = newRebootState;
523 return result?.ErrorMessage ==
null;
530 Logger.LogTrace(
"Resetting reboot state...");
531 ReattachInformation.RebootState =
RebootState.Normal;
547 ReattachInformation.Dmb = dmbProvider ??
throw new ArgumentNullException(nameof(dmbProvider));
552 public Task
InstanceRenamed(
string newInstanceName, CancellationToken cancellationToken)
554 ReattachInformation.RuntimeInformation.InstanceName = newInstanceName;
559 public Task
UpdateChannels(IEnumerable<ChannelRepresentation> newChannels, CancellationToken cancellationToken)
580 uint? startupTimeout,
584 var startTime = DateTimeOffset.UtcNow;
585 var useBridgeRequestForLaunchResult = !reattached && (apiValidate ||
DMApiAvailable);
586 var startupTask = useBridgeRequestForLaunchResult
587 ? initialBridgeRequestTcs.Task
591 if (startupTimeout.HasValue)
592 toAwait = Task.WhenAny(
595 TimeSpan.FromSeconds(startupTimeout.Value),
596 CancellationToken.None));
599 "Waiting for LaunchResult based on {launchResultCompletionCause}{possibleTimeout}...",
600 useBridgeRequestForLaunchResult ?
"initial bridge request" :
"process startup",
601 startupTimeout.HasValue ? $
" with a timeout of {startupTimeout.Value}s" : String.Empty);
607 ExitCode = process.Lifetime.IsCompleted ? await process.Lifetime :
null,
608 StartupTime = startupTask.IsCompleted ? (DateTimeOffset.UtcNow - startTime) :
null,
611 Logger.LogTrace(
"Launch result: {launchResult}", result);
613 if (!result.ExitCode.HasValue && reattached && !
disposed)
617 assemblyInformationProvider.
Version,
621 if (reattachResponse !=
null)
623 if (reattachResponse?.CustomCommands !=
null)
624 chatTrackingContext.CustomCommands = reattachResponse.CustomCommands;
625 else if (reattachResponse !=
null)
627 CompileJob.DMApiVersion >=
new Version(5, 2, 0)
630 "DMAPI Interop v{interopVersion} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
663 return BridgeError(
"Missing channelIds field in chatMessage!");
665 if (parameters.
ChatMessage.
ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
666 return BridgeError(
"Invalid channelIds in chatMessage!");
669 return BridgeError(
"Missing message field in chatMessage!");
671 var anyFailed =
false;
675 anyFailed |= !UInt64.TryParse(channelString, out var channelId);
680 return BridgeError(
"Failed to parse channelIds as U64!");
687 Interlocked.Exchange(ref
primeTcs,
new TaskCompletionSource()).SetResult();
690 Logger.LogInformation(
"Bridge requested process termination!");
700 Logger.LogWarning(
"DreamDaemon sent new port command without providing it's own!");
701 return BridgeError(
"Missing stringified port as data parameter!");
706 ReattachInformation.Port = parameters.
CurrentPort.Value;
712 ReattachInformation.Port =
nextPort.Value;
718 tmpTcs.SetResult(
true);
727 if (parameters.
Version ==
null)
749 return BridgeError(
"Missing minimumSecurityLevel field!");
751 return BridgeError(
"Invalid minimumSecurityLevel!");
768 Interlocked.Exchange(ref
startupTcs,
new TaskCompletionSource()).SetResult();
776 chatTrackingContext.Active =
false;
777 response.NewPort = 0;
781 Interlocked.Exchange(ref
rebootTcs,
new TaskCompletionSource()).SetResult();
782 await
RebootGate.WithToken(cancellationToken);
795 return BridgeError($
"commandType {parameters.CommandType} not supported!");
808 Logger.LogWarning(
"Bridge request chunking error: {message}", message);
811 ErrorMessage = message,
826 if (LogTopicRequests)
827 Logger.LogTrace(
"Topic request: {json}", json);
828 var fullCommandByteCount = Encoding.UTF8.GetByteCount(fullCommandString);
831 return await
SendRawTopic(fullCommandString, topicPriority, cancellationToken);
833 var interopChunkingVersion =
new Version(5, 6, 0);
837 "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}!",
839 fullCommandByteCount,
840 interopChunkingVersion);
847 parameters.AccessIdentifier =
null;
852 var fullPayloadSize = (uint)json.Length;
854 List<string> chunkQueryStrings =
null;
855 for (var chunkCount = 2; chunkQueryStrings ==
null; ++chunkCount)
857 var standardChunkSize = fullPayloadSize / chunkCount;
858 var bigChunkSize = standardChunkSize + (fullPayloadSize % chunkCount);
862 chunkQueryStrings =
new List<string>();
863 for (var i = 0U; i < chunkCount; ++i)
865 var startIndex = i * standardChunkSize;
866 var subStringLength = Math.Min(
867 fullPayloadSize - startIndex,
870 : standardChunkSize);
871 var chunkPayload = json.Substring((
int)startIndex, (
int)subStringLength);
875 Payload = chunkPayload,
876 PayloadId = payloadId,
878 TotalChunks = (uint)chunkCount,
890 chunkQueryStrings =
null;
894 chunkQueryStrings.Add(chunkCommandString);
898 Logger.LogTrace(
"Chunking topic request ({totalChunks} total)...", chunkQueryStrings.Count);
901 bool LogRequestIssue(
bool possiblyFromCompletedRequest)
906 "Topic request {chunkingStatus} failed!{potentialRequestError}",
907 possiblyFromCompletedRequest ?
"final chunk" :
"chunking",
908 combinedResponse?.InteropResponse?.ErrorMessage !=
null
909 ? $
" Request error: {combinedResponse.InteropResponse.ErrorMessage}"
917 foreach (var chunkCommandString
in chunkQueryStrings)
919 combinedResponse = await
SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
920 if (LogRequestIssue(chunkCommandString == chunkQueryStrings.Last()))
924 while ((combinedResponse.InteropResponse.MissingChunks?.Count ?? 0) > 0)
926 Logger.LogWarning(
"DD is still missing some chunks of topic request P{payloadId}! Sending missing chunks...", payloadId);
927 var lastIndex = combinedResponse.InteropResponse.MissingChunks.Last();
928 foreach (var missingChunkIndex
in combinedResponse.InteropResponse.MissingChunks)
930 var chunkCommandString = chunkQueryStrings[(int)missingChunkIndex];
931 combinedResponse = await
SendRawTopic(chunkCommandString, topicPriority, cancellationToken);
932 if (LogRequestIssue(missingChunkIndex == lastIndex))
937 return combinedResponse;
949 var commandString = String.Format(
950 CultureInfo.InvariantCulture,
954 return commandString;
964 async Task<CombinedTopicResponse>
SendRawTopic(
string queryString,
bool priority, CancellationToken cancellationToken)
968 global::Byond.TopicSender.TopicResponse byondResponse =
null;
969 var firstSend =
true;
971 const int PrioritySendAttempts = 5;
972 for (var i = PrioritySendAttempts - 1; i >= 0 && (priority || firstSend); --i)
976 if (!killedOrRebootedTask.IsCompleted)
978 new IPEndPoint(IPAddress.Loopback, targetPort),
986 Logger.LogWarning(ex,
"SendTopic exception!{retryDetails}", priority ? $
" {i} attempts remaining." : String.Empty);
988 if (priority && i > 0)
991 await Task.WhenAny(killedOrRebootedTask, delayTask);
995 if (byondResponse ==
null)
998 if (killedOrRebootedTask.IsCompleted)
1000 "Unable to send priority topic \"{queryString}\" DreamDaemon {stateClearAction}!",
1002 Lifetime.IsCompleted ?
"process ended" :
"rebooted");
1005 "Unable to send priority topic \"{queryString}\"!",
1011 var topicReturn = byondResponse.StringData;
1014 if (topicReturn !=
null)
1019 catch (Exception ex)
1021 Logger.LogWarning(ex,
"Invalid interop response: {topicReturnString}", topicReturn);
Metadata about a server instance.
Parameters for a bridge request.
BridgeCommandType? CommandType
The BridgeCommandType.
Version Version
The DMAPI global::System.Version for BridgeCommandType.Startup requests.
ChatMessage ChatMessage
The Interop.ChatMessage for BridgeCommandType.ChatSend requests.
ushort? CurrentPort
The current port for BridgeCommandType.PortUpdate requests.
ICollection< CustomCommand > CustomCommands
The DMAPI CustomCommands for BridgeCommandType.Startup requests.
ChunkData Chunk
The ChunkData for BridgeCommandType.Chunk requests.
DreamDaemonSecurity? MinimumSecurityLevel
The minimum required DreamDaemonSecurity level for BridgeCommandType.Startup requests.
A response to a bridge request.
ICollection< string > ChannelIds
The ICollection<T> of Chat.ChannelRepresentation.Ids to sent the MessageContent to....
Represents an update of ChannelRepresentations.
A packet of a split serialized set of data.
uint? PayloadId
The ID of the full request to differentiate different chunkings. Nullable to prevent default value om...
Class that deserializes chunked interop payloads.
ILogger< Chunker > Logger
The ILogger for the Chunker.
uint NextPayloadId
Gets a payload ID for use in a new ChunkSetInfo.
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 global::Byond.TopicSender.ITopicClient payload.
static readonly Version InteropVersion
The DMAPI InteropVersion being used.
const string TopicData
Parameter json is encoded in for topic requests.
Common base for interop parameters.
string AccessIdentifier
Used to identify and authenticate the DreamDaemon instance.
string ErrorMessage
Any errors in the client's parameters.
string Text
The message string.
TopicParameters for TopicCommandType.ReceiveChunk.
Parameters for a topic request.
TopicCommandType? CommandType
The TopicCommandType.
bool IsPriority
Whether or not the TopicParameters constitute a priority request.
A response to a topic request.
Combines a global::Byond.TopicSender.TopicResponse with a TopicResponse.
TopicResponse InteropResponse
The interop TopicResponse, if any.
Represents the result of trying to start a DD process.
ApiValidationStatus apiValidationStatus
The ApiValidationStatus for the SessionController.
Models.CompileJob CompileJob
Gets the CompileJob associated with the ISessionController.
async ValueTask DisposeAsync()
readonly object synchronizationLock
lock object for port updates and disposed.
void Suspend()
Suspends the process.
Task UpdateChannels(IEnumerable< ChannelRepresentation > newChannels, CancellationToken cancellationToken)
Called when newChannels are set. A Task representing the running operation.
void AdjustPriority(bool higher)
Set's the owned global::System.Diagnostics.Process.PriorityClass to a non-normal value.
volatile uint rebootBridgeRequestsProcessing
The number of currently active calls to ProcessBridgeRequest(BridgeParameters, CancellationToken) fro...
readonly Api.Models.Instance metadata
The Instance metadata.
Task OnReboot
A Task that completes when the server calls /world/TgsReboot().
Task< int > Lifetime
The Task<TResult> resulting in the exit code of the process.
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 TerminationWasRequested
If the DreamDaemon instance sent a.
Task CreateDump(string outputFile, CancellationToken cancellationToken)
Create a dump file of the process. A Task representing the running operation.
bool disposed
If the SessionController has been disposed.
void ResetRebootState()
Changes RebootState to RebootState.Normal without telling the DMAPI.
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the SessionController.
async Task< CombinedTopicResponse > SendRawTopic(string queryString, bool priority, CancellationToken cancellationToken)
Send a given queryString to DreamDaemon's /world/Topic.
async Task Release()
Releases the IProcess without terminating it. Also calls IDisposable.Dispose. A Task representing the...
Task< bool > SetPort(ushort port, CancellationToken cancellationToken)
Causes the world to start listening on a newPort . A Task<TResult> resulting in true if the operation...
ReattachInformation ReattachInformation
The up to date Session.ReattachInformation.
readonly CancellationTokenSource reattachTopicCts
A CancellationTokenSource used for the topic send operation made on reattaching.
volatile Task rebootGate
Backing field for RebootGate.
BridgeResponse BridgeError(string message)
Log and return a BridgeResponse for a given message .
bool released
If process should be kept alive instead.
async Task< BridgeResponse > ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
Handle a set of bridge parameters .
readonly IChatManager chat
The IChatManager for the SessionController.
async Task< bool > SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
Attempts to change the current RebootState to newRebootState . A Task<TResult> resulting in true if t...
readonly IChatTrackingContext chatTrackingContext
The IChatTrackingContext for the SessionController.
bool ClosePortOnReboot
If the port should be rotated off when the world reboots.
Task InstanceRenamed(string newInstanceName, CancellationToken cancellationToken)
Called when the owning Instance is renamed. A Task representing the running operation.
volatile TaskCompletionSource startupTcs
The TaskCompletionSource that completes when DD sends a valid startup bridge request.
readonly IByondExecutableLock byondLock
The IByondExecutableLock for the SessionController.
async Task< CombinedTopicResponse > SendTopicRequest(TopicParameters parameters, CancellationToken cancellationToken)
Send a topic request for given parameters to DreamDaemon, chunking it if necessary.
async Task< BridgeResponse > ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
Handle a set of bridge parameters . A Task<TResult> resulting in the BridgeResponse for the request o...
SessionController(ReattachInformation reattachInformation, Api.Models.Instance metadata, IProcess process, IByondExecutableLock byondLock, global::Byond.TopicSender.ITopicClient byondTopicSender, IChatTrackingContext chatTrackingContext, IBridgeRegistrar bridgeRegistrar, IChatManager chat, IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, ILogger< SessionController > logger, Func< Task > postLifetimeCallback, uint? startupTimeout, bool reattached, bool apiValidate)
Initializes a new instance of the SessionController class.
IDisposable ReplaceDmbProvider(IDmbProvider dmbProvider)
Replace the IDmbProvider in use with a given newProvider , disposing the old one. An IDisposable to b...
string GenerateQueryString(TopicParameters parameters, out string json)
Generates a global::Byond.TopicSender.ITopicClient query string for a given set of parameters .
Version DMApiVersion
The DMAPI Version.
readonly global::Byond.TopicSender.ITopicClient byondTopicSender
The global::Byond.TopicSender.ITopicClient for the SessionController.
bool portClosedForReboot
If we know DreamDaemon currently has it's port closed.
void CheckDisposed()
Throws an ObjectDisposedException if DisposeAsync has been called.
void EnableCustomChatCommands()
Enables the reading of custom chat commands from the ISessionController.
async Task< LaunchResult > GetLaunchResult(IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, uint? startupTimeout, bool reattached, bool apiValidate)
The Task<TResult> for LaunchResult.
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().
void Resume()
Resumes the process.
ushort? nextPort
The port to assign DreamDaemon when it queries for it.
Task OnPrime
A Task that completes when the server calls /world/TgsInitializationComplete().
TaskCompletionSource< bool > portAssignmentTcs
The TaskCompletionSource<TResult> SetPort(ushort, CancellationToken) waits on when DreamDaemon curren...
readonly IBridgeRegistration bridgeRegistration
The IBridgeRegistration for the SessionController.
volatile TaskCompletionSource rebootTcs
The TaskCompletionSource that completes when DD tells us about a reboot.
volatile TaskCompletionSource primeTcs
The TaskCompletionSource that completes when DD tells us it's primed.
Task RebootGate
A Task that must complete before a TgsReboot() bridge request can complete.
readonly IProcess process
The IProcess for the SessionController.
async Task< TopicResponse > SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
Sends a command to DreamDaemon through /world/Topic(). A Task<TResult> resulting in the TopicResponse...
Helpers for manipulating the Serilog.Context.LogContext.
const string InstanceIdContextProperty
The Serilog.Context.LogContext property name for Models.Instance Api.Models.EntityId....
Represents usage of the two primary BYOND server executables.
void DoNotDeleteThisSession()
Call if, during a detach, this version should not be deleted.
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.
CompileJob CompileJob
The CompileJob of the .dmb.
Registers IBridgeHandlers.
IBridgeRegistration RegisterHandler(IBridgeHandler bridgeHandler)
Register a given bridgeHandler .
Represents a registration of an interop session.
Handles communication with a DreamDaemon IProcess.
Task< int > Lifetime
The Task<TResult> resulting in the exit code of the process.
void Suspend()
Suspends the process.
void Resume()
Resumes the process.
void AdjustPriority(bool higher)
Set's the owned global::System.Diagnostics.Process.PriorityClass to a non-normal value.
Task CreateDump(string outputFile, CancellationToken cancellationToken)
Create a dump file of the process.
Abstraction over a global::System.Diagnostics.Process.
Task Startup
The Task representing the time until the IProcess becomes "idle".
void Terminate()
Asycnhronously terminates the process.
For waiting asynchronously.
Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken)
Create a Task that completes after a given timeSpan .
DreamDaemonSecurity
DreamDaemon's security level.
BridgeCommandType
Represents the BridgeParameters.CommandType.
RebootState
Represents the action to take when /world/Reboot() is called.
ApiValidationStatus
Status of DMAPI validation.