tgstation-server  4.3.2
The /tg/station 13 server suite
SessionController.cs
Go to the documentation of this file.
1 using Byond.TopicSender;
2 using Microsoft.Extensions.Logging;
3 using Newtonsoft.Json;
4 using Serilog.Context;
5 using System;
6 using System.Collections.Generic;
7 using System.Globalization;
8 using System.Linq;
9 using System.Net;
10 using System.Threading;
11 using System.Threading.Tasks;
12 using Tgstation.Server.Api;
21 
22 namespace Tgstation.Server.Host.Components.Session
23 {
26  {
28  public DMApiParameters DMApiParameters => reattachInformation;
29 
31  public bool IsPrimary
32  {
33  get
34  {
35  CheckDisposed();
36  return reattachInformation.IsPrimary;
37  }
38  }
39 
42  {
43  get
44  {
45  if (!Lifetime.IsCompleted)
46  throw new InvalidOperationException("ApiValidated cannot be checked while Lifetime is incomplete!");
47  return apiValidationStatus;
48  }
49  }
50 
52  public IDmbProvider Dmb
53  {
54  get
55  {
56  CheckDisposed();
57  return reattachInformation.Dmb;
58  }
59  }
60 
62  public ushort? Port
63  {
64  get
65  {
66  CheckDisposed();
67  if (portClosedForReboot)
68  return null;
69  return reattachInformation.Port;
70  }
71  }
72 
75  {
76  get
77  {
78  CheckDisposed();
79  return reattachInformation.RebootState;
80  }
81  }
82 
84  public Version DMApiVersion { get; private set; }
85 
87  public bool ClosePortOnReboot { get; set; }
88 
90  public bool TerminationWasRequested { get; private set; }
91 
93  public Task<LaunchResult> LaunchResult { get; }
94 
96  public Task<int> Lifetime => process.Lifetime;
97 
99  public Task OnReboot => rebootTcs.Task;
100 
102  public Task OnPrime => primeTcs.Task;
103 
108 
112  readonly Api.Models.Instance metadata;
113 
117  readonly CancellationTokenSource reattachTopicCts;
118 
122  readonly ITopicClient byondTopicSender;
123 
128 
132  readonly IProcess process;
133 
138 
143 
147  readonly IChatManager chat;
148 
152  readonly ILogger<SessionController> logger;
153 
157  readonly object synchronizationLock;
158 
162  TaskCompletionSource<bool> portAssignmentTcs;
163 
167  ushort? nextPort;
168 
172  TaskCompletionSource<object> rebootTcs;
173 
177  TaskCompletionSource<object> primeTcs;
178 
183 
187  bool disposed;
188 
193 
197  bool released;
198 
215  ReattachInformation reattachInformation,
216  Api.Models.Instance metadata,
217  IProcess process,
218  IByondExecutableLock byondLock,
219  ITopicClient byondTopicSender,
220  IChatTrackingContext chatTrackingContext,
221  IBridgeRegistrar bridgeRegistrar,
222  IChatManager chat,
223  IAssemblyInformationProvider assemblyInformationProvider,
224  ILogger<SessionController> logger,
225  uint? startupTimeout,
226  bool reattached)
227  {
228  this.reattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
229  this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
230  this.process = process ?? throw new ArgumentNullException(nameof(process));
231  this.byondLock = byondLock ?? throw new ArgumentNullException(nameof(byondLock));
232  this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
233  this.chatTrackingContext = chatTrackingContext ?? throw new ArgumentNullException(nameof(chatTrackingContext));
234  bridgeRegistration = bridgeRegistrar?.RegisterHandler(this) ?? throw new ArgumentNullException(nameof(bridgeRegistrar));
235  this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
236  this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
237 
238  this.chatTrackingContext.SetChannelSink(this);
239 
240  portClosedForReboot = false;
241  disposed = false;
242  apiValidationStatus = ApiValidationStatus.NeverValidated;
243  released = false;
244 
245  rebootTcs = new TaskCompletionSource<object>();
246  primeTcs = new TaskCompletionSource<object>();
247  reattachTopicCts = new CancellationTokenSource();
248  synchronizationLock = new object();
249 
250  _ = process.Lifetime.ContinueWith(
251  x =>
252  {
253  lock (synchronizationLock)
254  if (!disposed)
255  reattachTopicCts.Cancel();
256  chatTrackingContext.Active = false;
257  },
258  TaskScheduler.Current);
259 
260  LaunchResult = GetLaunchResult(
261  assemblyInformationProvider,
262  startupTimeout,
263  reattached);
264 
265  logger.LogDebug("Created session controller. Primary: {0}, CommsKey: {1}, Port: {2}", IsPrimary, reattachInformation.AccessIdentifier, Port);
266  }
267 
272 #pragma warning disable CA1821 // Remove empty Finalizers TODO: remove this when https://github.com/dotnet/roslyn-analyzers/issues/1241 is fixed
273  ~SessionController() => Dispose(false);
274 #pragma warning restore CA1821 // Remove empty Finalizers
275 
277  public void Dispose()
278  {
279  Dispose(true);
280  GC.SuppressFinalize(this);
281  }
282 
287  void Dispose(bool disposing)
288  {
289  lock (synchronizationLock)
290  {
291  if (disposed)
292  return;
293  disposed = true;
294  logger.LogTrace("Disposing...");
295  if (disposing)
296  {
297  if (!released)
298  {
299  process.Terminate();
300  byondLock.Dispose();
301  }
302 
303  process.Dispose();
304  bridgeRegistration.Dispose();
305  reattachInformation.Dmb?.Dispose(); // will be null when released
306  chatTrackingContext.Dispose();
307  reattachTopicCts.Dispose();
308  }
309  else
310  {
311  if (logger != null)
312  logger.LogError("Being disposed via finalizer!");
313  if (!released)
314  if (process != null)
315  process.Terminate();
316  else if (logger != null)
317  logger.LogCritical("Unable to terminate active DreamDaemon session due to finalizer ordering!");
318  }
319  }
320  }
321 
329  async Task<LaunchResult> GetLaunchResult(
330  IAssemblyInformationProvider assemblyInformationProvider,
331  uint? startupTimeout,
332  bool reattached)
333  {
334  var startTime = DateTimeOffset.Now;
335  Task toAwait = process.Startup;
336 
337  if (startupTimeout.HasValue)
338  toAwait = Task.WhenAny(process.Startup, Task.Delay(startTime.AddSeconds(startupTimeout.Value) - startTime));
339 
340  await toAwait.ConfigureAwait(false);
341 
342  var result = new LaunchResult
343  {
344  ExitCode = process.Lifetime.IsCompleted ? (int?)await process.Lifetime.ConfigureAwait(false) : null,
345  StartupTime = process.Startup.IsCompleted ? (TimeSpan?)(DateTimeOffset.Now - startTime) : null
346  };
347 
348  logger.LogTrace("Launch result: {0}", result);
349 
350  if (!result.ExitCode.HasValue && reattached && !disposed)
351  {
352  var reattachResponse = await SendCommand(
353  new TopicParameters(
354  assemblyInformationProvider.Version,
355  reattachInformation.RuntimeInformation.ServerPort),
356  reattachTopicCts.Token)
357  .ConfigureAwait(false);
358 
359  if (reattachResponse.InteropResponse?.CustomCommands != null)
360  chatTrackingContext.CustomCommands = reattachResponse.InteropResponse.CustomCommands;
361  else if (reattachResponse.InteropResponse != null)
362  logger.LogWarning(
363  "DMAPI v{0} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
364  Dmb.CompileJob.DMApiVersion.Semver());
365  }
366 
367  return result;
368  }
369 
371  public async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
372  {
373  if (parameters == null)
374  throw new ArgumentNullException(nameof(parameters));
375 
376  using (LogContext.PushProperty("Instance", metadata.Id))
377  {
378  logger.LogTrace("Handling bridge request...");
379  var response = new BridgeResponse();
380  switch (parameters.CommandType)
381  {
382  case BridgeCommandType.ChatSend:
383  if (parameters.ChatMessage == null)
384  return new BridgeResponse
385  {
386  ErrorMessage = "Missing chatMessage field!"
387  };
388 
389  if (parameters.ChatMessage.ChannelIds == null)
390  return new BridgeResponse
391  {
392  ErrorMessage = "Missing channelIds field in chatMessage!"
393  };
394 
395  if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
396  return new BridgeResponse
397  {
398  ErrorMessage = "Invalid channelIds in chatMessage!"
399  };
400 
401  if (parameters.ChatMessage.Text == null)
402  return new BridgeResponse
403  {
404  ErrorMessage = "Missing message field in chatMessage!"
405  };
406 
407  await chat.SendMessage(
408  parameters.ChatMessage.Text,
409  parameters.ChatMessage.ChannelIds.Select(UInt64.Parse),
410  cancellationToken).ConfigureAwait(false);
411  break;
412  case BridgeCommandType.Prime:
413  var oldPrimeTcs = primeTcs;
414  primeTcs = new TaskCompletionSource<object>();
415  oldPrimeTcs.SetResult(null);
416  break;
417  case BridgeCommandType.Kill:
418  logger.LogInformation("Bridge requested process termination!");
419  TerminationWasRequested = true;
420  process.Terminate();
421  break;
422  case BridgeCommandType.PortUpdate:
423  lock (synchronizationLock)
424  {
425  if (!parameters.CurrentPort.HasValue)
426  {
428  logger.LogWarning("DreamDaemon sent new port command without providing it's own!");
429  return new BridgeResponse
430  {
431  ErrorMessage = "Missing stringified port as data parameter!"
432  };
433  }
434 
435  var currentPort = parameters.CurrentPort.Value;
436  if (!nextPort.HasValue)
437  reattachInformation.Port = parameters.CurrentPort.Value; // not ready yet, so what we'll do is accept the random port DD opened on for now and change it later when we decide to
438  else
439  {
440  // nextPort is ready, tell DD to switch to that
441  // if it fails it'll kill itself
442  response.NewPort = nextPort.Value;
443  reattachInformation.Port = nextPort.Value;
444  nextPort = null;
445 
446  // we'll also get here from SetPort so complete that task
447  var tmpTcs = portAssignmentTcs;
448  portAssignmentTcs = null;
449  tmpTcs.SetResult(true);
450  }
451 
452  portClosedForReboot = false;
453  }
454 
455  break;
456  case BridgeCommandType.Startup:
457  apiValidationStatus = ApiValidationStatus.BadValidationRequest;
458  if (parameters.Version == null)
459  return new BridgeResponse
460  {
461  ErrorMessage = "Missing dmApiVersion field!"
462  };
463 
464  DMApiVersion = parameters.Version;
465  switch (parameters.MinimumSecurityLevel)
466  {
467  case DreamDaemonSecurity.Ultrasafe:
468  apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
469  break;
470  case DreamDaemonSecurity.Safe:
471  apiValidationStatus = ApiValidationStatus.RequiresSafe;
472  break;
473  case DreamDaemonSecurity.Trusted:
474  apiValidationStatus = ApiValidationStatus.RequiresTrusted;
475  break;
476  case null:
477  return new BridgeResponse
478  {
479  ErrorMessage = "Missing minimumSecurityLevel field!"
480  };
481  default:
482  return new BridgeResponse
483  {
484  ErrorMessage = "Invalid minimumSecurityLevel!"
485  };
486  }
487 
488  response.RuntimeInformation = reattachInformation.RuntimeInformation;
489 
490  // Load custom commands
491  chatTrackingContext.CustomCommands = parameters.CustomCommands;
492  break;
493  case BridgeCommandType.Reboot:
494  if (ClosePortOnReboot)
495  {
496  chatTrackingContext.Active = false;
497  response.NewPort = 0;
498  portClosedForReboot = true;
499  }
500 
501  var oldRebootTcs = rebootTcs;
502  rebootTcs = new TaskCompletionSource<object>();
503  oldRebootTcs.SetResult(null);
504  break;
505  case null:
506  response.ErrorMessage = "Missing commandType!";
507  break;
508  default:
509  response.ErrorMessage = "Requested commandType not supported!";
510  break;
511  }
512 
513  return response;
514  }
515  }
516 
521  {
522  if (disposed)
523  throw new ObjectDisposedException(nameof(SessionController));
524  }
525 
527  public void EnableCustomChatCommands() => chatTrackingContext.Active = true;
528 
531  {
532  CheckDisposed();
533 
534  // we still don't want to dispose the dmb yet, even though we're keeping it alive
535  var tmpProvider = reattachInformation.Dmb;
536  reattachInformation.Dmb = null;
537  released = true;
538  Dispose();
539  byondLock.DoNotDeleteThisSession();
540  tmpProvider.KeepAlive();
541  reattachInformation.Dmb = tmpProvider;
542  return reattachInformation;
543  }
544 
546  public async Task<CombinedTopicResponse> SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
547  {
548  if (Lifetime.IsCompleted)
549  {
550  logger.LogWarning(
551  "Attempted to send a command to an inactive SessionController: {0}",
552  parameters.CommandType);
553  return null;
554  }
555 
556  parameters.AccessIdentifier = reattachInformation.AccessIdentifier;
557 
558  var json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
559  logger.LogTrace("Topic request: {0}", json);
560  try
561  {
562  var commandString = String.Format(CultureInfo.InvariantCulture,
563  "?{0}={1}",
564  byondTopicSender.SanitizeString(DMApiConstants.TopicData),
565  byondTopicSender.SanitizeString(json));
566 
567  var targetPort = reattachInformation.Port;
568 
569  var topicResponse = await byondTopicSender.SendTopic(
570  new IPEndPoint(IPAddress.Loopback, targetPort),
571  commandString,
572  cancellationToken).ConfigureAwait(false);
573 
574  var topicReturn = topicResponse.StringData;
575 
576  Interop.Topic.TopicResponse interopResponse = null;
577  if (topicReturn != null)
578  try
579  {
580  interopResponse = JsonConvert.DeserializeObject<Interop.Topic.TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
581  if (interopResponse.ErrorMessage != null)
582  {
583  logger.LogWarning("Errored topic response for command {0}: {1}", parameters.CommandType, interopResponse.ErrorMessage);
584  }
585 
586  logger.LogTrace("Interop response: {0}", topicReturn);
587  }
588  catch
589  {
590  logger.LogWarning("Invalid interop response: {0}", topicReturn);
591  }
592 
593  return new CombinedTopicResponse(topicResponse, interopResponse);
594  }
595  catch (OperationCanceledException)
596  {
597  logger.LogTrace(
598  "Topic request {0}!",
599  cancellationToken.IsCancellationRequested
600  ? "aborted"
601  : "timed out");
602  cancellationToken.ThrowIfCancellationRequested();
603  }
604  catch (Exception e)
605  {
606  logger.LogWarning("Send command exception:{0}{1}", Environment.NewLine, e);
607  }
608 
609  return null;
610  }
611 
613  public Task<bool> SetPort(ushort port, CancellationToken cancellationToken)
614  {
615  CheckDisposed();
616 
617  if (port == 0)
618  throw new ArgumentOutOfRangeException(nameof(port), port, "port must not be zero!");
619 
620  async Task<bool> ImmediateTopicPortChange()
621  {
622  var commandResult = await SendCommand(
623  new TopicParameters(port),
624  cancellationToken)
625  .ConfigureAwait(false);
626 
627  if (commandResult.InteropResponse?.ErrorMessage != null)
628  return false;
629 
630  reattachInformation.Port = port;
631  return true;
632  }
633 
634  lock (synchronizationLock)
635  if (portClosedForReboot)
636  {
637  if (portAssignmentTcs != null)
638  throw new InvalidOperationException("A port change operation is already in progress!");
639  nextPort = port;
640  portAssignmentTcs = new TaskCompletionSource<bool>();
641  return portAssignmentTcs.Task;
642  }
643  else
644  return ImmediateTopicPortChange();
645  }
646 
648  public async Task<bool> SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
649  {
650  if (RebootState == newRebootState)
651  return true;
652 
653  logger.LogTrace("Changing reboot state to {0}", newRebootState);
654 
655  reattachInformation.RebootState = newRebootState;
656  var result = await SendCommand(
657  new TopicParameters(newRebootState),
658  cancellationToken)
659  .ConfigureAwait(false);
660 
661  return result?.InteropResponse != null && result.InteropResponse?.ErrorMessage == null;
662  }
663 
665  public void ResetRebootState()
666  {
667  CheckDisposed();
668  logger.LogTrace("Resetting reboot state...");
669  reattachInformation.RebootState = RebootState.Normal;
670  }
671 
673  public void SetHighPriority() => process.SetHighPriority();
674 
676  public void Suspend() => process.Suspend();
677 
679  public void Resume() => process.Resume();
680 
682  public void ReplaceDmbProvider(IDmbProvider dmbProvider)
683  {
684  var oldDmb = reattachInformation.Dmb;
685  reattachInformation.Dmb = dmbProvider ?? throw new ArgumentNullException(nameof(dmbProvider));
686  oldDmb.Dispose();
687  }
688 
690  public Task InstanceRenamed(string newInstanceName, CancellationToken cancellationToken)
691  => SendCommand(new TopicParameters(newInstanceName), cancellationToken);
692 
694  public Task UpdateChannels(IEnumerable<ChannelRepresentation> newChannels, CancellationToken cancellationToken)
695  => SendCommand(
696  new TopicParameters(
697  new ChatUpdate(newChannels)),
698  cancellationToken);
699  }
700 }
readonly CancellationTokenSource reattachTopicCts
A CancellationTokenSource used for the topic send operation made on reattaching.
ApiValidationStatus
Status of DMAPI validation
readonly IChatManager chat
The IChatManager for the SessionController
Task< int > Lifetime
The Task<TResult> resulting in the exit code of the process
Definition: IProcessBase.cs:14
readonly object synchronizationLock
object for port updates and disposed.
ushort nextPort
The port to assign DreamDaemon when it queries for it
ApiValidationStatus apiValidationStatus
The ApiValidationStatus for the SessionController
Version Version
The DMAPI global::System.Version for BridgeCommandType.Startup requests.
void CheckDisposed()
Throws an ObjectDisposedException if Dispose(bool) has been called
readonly IProcess process
The IProcess for the SessionController
readonly Api.Models.Instance metadata
The Instance metadata.
ICollection< CustomCommand > CustomCommands
The DMAPI CustomCommands for BridgeCommandType.Startup requests.
readonly ReattachInformation reattachInformation
The up to date ReattachInformation
TaskCompletionSource< bool > portAssignmentTcs
The TaskCompletionSource<TResult> SetPort(ushort, CancellationToken) waits on when DreamDaemon curren...
ChatMessage ChatMessage
The Interop.ChatMessage for BridgeCommandType.ChatSend requests.
void ReplaceDmbProvider(IDmbProvider dmbProvider)
Replace Dmb with a given newProvider , disposing the old one.
Parameters necessary for duplicating a ISessionController session
async Task< CombinedTopicResponse > SendCommand(TopicParameters parameters, CancellationToken cancellationToken)
Sends a command to DreamDaemon through /world/Topic()
IBridgeRegistration RegisterHandler(IBridgeHandler bridgeHandler)
Register a given bridgeHandler .
bool portClosedForReboot
If we know DreamDaemon currently has it&#39;s port closed
const string TopicData
Parameter json is encoded in for topic requests.
DreamDaemonSecurity MinimumSecurityLevel
The minimum required DreamDaemonSecurity level for BridgeCommandType.Startup requests.
Notifyee of when ChannelRepresentations in a IChatTrackingContext are updated.
Definition: IChannelSink.cs:10
Constants used for communication with the DMAPI
Represents the result of trying to start a DD process
Definition: LaunchResult.cs:9
readonly IBridgeRegistration bridgeRegistration
The IBridgeRegistration for the SessionController
bool released
If process should be kept alive instead
For managing connected chat services
Definition: IChatManager.cs:13
ushort CurrentPort
The current port for BridgeCommandType.PortUpdate requests.
Task< bool > SetPort(ushort port, CancellationToken cancellationToken)
Causes the world to start listening on a newPort
void Dispose(bool disposing)
Implements the IDisposable pattern
RebootState
Represents the action to take when /world/Reboot() is called
Definition: RebootState.cs:6
async Task< BridgeResponse > ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
Handle a set of bridge parameters .
readonly IByondExecutableLock byondLock
The IByondExecutableLock for the SessionController
static readonly JsonSerializerSettings SerializerSettings
JsonSerializerSettings for use when communicating with the DMAPI.
bool Active
If the CustomCommands should be used.
ReattachInformation Release()
Releases the IProcess without terminating it. Also calls IDisposable.Dispose
string AccessIdentifier
Used to identify and authenticate the DreamDaemon instance
Abstraction over a global::System.Diagnostics.Process
Definition: IProcess.cs:9
readonly ILogger< SessionController > logger
The ILogger for the SessionController
Represents usage of the two primary BYOND server executables
Handles communication with a DreamDaemon IProcess
ICollection< string > ChannelIds
The ICollection<T> of Chat.ChannelRepresentation.Ids to sent the Text to. Must be safe to parse as ul...
Definition: ChatMessage.cs:18
async Task< bool > SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
Attempts to change the current RebootState to newRebootState
BridgeCommandType
Represents the BridgeParameters.CommandType.
Represents a BYOND installation
Definition: Byond.cs:8
readonly ITopicClient byondTopicSender
The ITopicClient for the SessionController
Represents a tracking of dynamic chat json files
DreamDaemonSecurity
DreamDaemon&#39;s security level
Provides absolute paths to the latest compiled .dmbs
Definition: IDmbProvider.cs:9
readonly IChatTrackingContext chatTrackingContext
The IChatTrackingContext for the SessionController
void SetChannelSink(IChannelSink channelSink)
Sets the channelSink for the IChatTrackingContext.
SessionController(ReattachInformation reattachInformation, Api.Models.Instance metadata, IProcess process, IByondExecutableLock byondLock, ITopicClient byondTopicSender, IChatTrackingContext chatTrackingContext, IBridgeRegistrar bridgeRegistrar, IChatManager chat, IAssemblyInformationProvider assemblyInformationProvider, ILogger< SessionController > logger, uint?startupTimeout, bool reattached)
Construct a SessionController
Represents an error message returned by the server
Definition: ErrorMessage.cs:9
Represents an update of ChannelRepresentations.
Definition: ChatUpdate.cs:11
async Task< LaunchResult > GetLaunchResult(IAssemblyInformationProvider assemblyInformationProvider, uint?startupTimeout, bool reattached)
The Task<TResult> for LaunchResult.
Represents a registration of an interop session.
bool disposed
If the SessionController has been disposed
TaskCompletionSource< object > primeTcs
The TaskCompletionSource<TResult> that completes when DD tells us it&#39;s primed.
Combines a global::Byond.TopicSender.TopicResponse with a TopicResponse.
TaskCompletionSource< object > rebootTcs
The TaskCompletionSource<TResult> that completes when DD tells us about a reboot
void ResetRebootState()
Changes RebootState to RebootState.Normal without telling the DMAPI