Merge branch 'dev' of https://github.com/tgstation/tgstation-server into 979-EnablePostgres

This commit is contained in:
Jordan Brown
2020-06-04 17:06:45 -04:00
38 changed files with 428 additions and 175 deletions
+3
View File
@@ -48,6 +48,9 @@ The following environment variables aren't required but enable more tests.
### Know your Code
- All feature work should be submitted to the `dev` branch for the next minor release.
- All patch work should be submitted to the `master` branch for the next patch. Changes will be automatically integrated into `dev`
The `/src` folder at the root of this repository contains a series of `README.md` files useful for helping find your way around the codebase.
## Specifications
-19
View File
@@ -1,19 +0,0 @@
Note that this repository does not contain any client code (With the exception of the web control panel which can be found in [its own repository](https://github.com/tgstation/tgstation-server-control-panel)). Any client issues should be reported to their respective codebases.
Please include:
- The version of tgstation-server you were using.
- The operating system you are running it from.
- A description of the issue.
- A link to your codebase git (if public)
- Include active SHA/test merges if applicable
- The client you we're using (Desktop control panel, web control panel, etc)
- Include a version if applicable
- Reproduction steps for the issue if possible from your client.
- The server log of when the event happened (The full file is much more useful than snippets).
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug Report
about: Report a problem with the server
title: ''
labels: Bug, Reproduction Required, Ready
assignees: Cyberboss
---
**Describe the bug**
A clear and concise description of what the bug is. Please note that client issues (i.e. Control panel crashes) do not belong in this repo and should be report in their own.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs**
Please include full server logs to help diagnose your problem
**Server State: (please complete the following information):**
- OS:
- Version:
- BYOND Version Used:
- git Repository Used:
- Origin Commit hash Used:
- Active Test Merges:
- Client Version:
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for the server
title: ''
labels: Feature Request, Backlog
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- This is the authorative version list -->
<!-- Integration tests will ensure they match across the board -->
<TgsCoreVersion>4.3.1</TgsCoreVersion>
<TgsApiVersion>6.5.1</TgsApiVersion>
<TgsClientVersion>7.1.1</TgsClientVersion>
<TgsCoreVersion>4.3.2</TgsCoreVersion>
<TgsApiVersion>6.6.0</TgsApiVersion>
<TgsClientVersion>7.2.0</TgsClientVersion>
<TgsDmapiVersion>5.2.2</TgsDmapiVersion>
<TgsControlPanelVersion>0.4.0</TgsControlPanelVersion>
<TgsHostWatchdogVersion>1.1.0</TgsHostWatchdogVersion>
+1 -1
View File
@@ -50,7 +50,7 @@ TGS_PROTECT_DATUM(/datum/tgs_api)
/datum/tgs_api/proc/ChatTargetedBroadcast(message, admin_only)
return TGS_UNIMPLEMENTED
/datum/tgs_api/proc/ChatPrivateMessage(message, admin_only)
/datum/tgs_api/proc/ChatPrivateMessage(message, datum/tgs_chat_user/user)
return TGS_UNIMPLEMENTED
/datum/tgs_api/proc/SecurityLevel()
@@ -489,5 +489,11 @@ namespace Tgstation.Server.Api.Models
/// </summary>
[Description("The deployment succeeded but one or more notification events failed!")]
PostDeployFailure,
/// <summary>
/// Attempted to restart a stopped watchdog.
/// </summary>
[Description("Cannot restart the watchdog as it is not running!")]
WatchdogNotRunning,
}
}
@@ -48,16 +48,14 @@ namespace Tgstation.Server.Api.Models.Internal
public uint? HeartbeatSeconds { get; set; }
/// <summary>
/// Check if we match a given set of <paramref name="otherParameters"/>
/// Check if we match a given set of <paramref name="otherParameters"/>. <see cref="StartupTimeout"/> is excluded.
/// </summary>
/// <param name="otherParameters">The <see cref="DreamDaemonLaunchParameters"/> to compare against</param>
/// <returns><see langword="true"/> if they match, <see langword="false"/> otherwise</returns>
public bool Match(DreamDaemonLaunchParameters otherParameters) =>
public bool CanApplyWithoutReboot(DreamDaemonLaunchParameters otherParameters) =>
AllowWebClient == (otherParameters?.AllowWebClient ?? throw new ArgumentNullException(nameof(otherParameters)))
&& SecurityLevel == otherParameters.SecurityLevel
&& PrimaryPort == otherParameters.PrimaryPort
&& SecondaryPort == otherParameters.SecondaryPort
&& StartupTimeout == otherParameters.StartupTimeout
&& HeartbeatSeconds == otherParameters.HeartbeatSeconds;
&& SecondaryPort == otherParameters.SecondaryPort; // We intentionally don't check StartupTimeout or heartbeat seconds as it doesn't matter in terms of the watchdog
}
}
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -141,8 +141,11 @@ namespace Tgstation.Server.Host.Components.Deployment
{
nextDmbProvider?.Dispose();
nextDmbProvider = newProvider;
newerDmbTcs.SetResult(nextDmbProvider);
// Oh god dammit
var temp = newerDmbTcs;
newerDmbTcs = new TaskCompletionSource<object>();
temp.SetResult(nextDmbProvider);
}
}
@@ -12,6 +12,7 @@ using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Byond;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Components.Session;
using Tgstation.Server.Host.Core;
@@ -585,7 +586,7 @@ namespace Tgstation.Server.Host.Components.Deployment
}
catch
{
repo.Dispose();
repo?.Dispose();
throw;
}
})
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using Tgstation.Server.Host.Components.StaticFiles;
using Tgstation.Server.Host.Components.Watchdog;
namespace Tgstation.Server.Host.Components
namespace Tgstation.Server.Host.Components.Events
{
/// <inheritdoc />
sealed class EventConsumer : IEventConsumer
@@ -0,0 +1,25 @@
using System;
namespace Tgstation.Server.Host.Components.Events
{
/// <summary>
/// Attribute for indicating the script that a given <see cref="EventType"/> runs.
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
sealed class EventScriptAttribute : Attribute
{
/// <summary>
/// The name of the script the event script the <see cref="EventType"/> runs.
/// </summary>
public string ScriptName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="EventScriptAttribute"/> <see langword="class"/>.
/// </summary>
/// <param name="scriptName">The value of <see cref="ScriptName"/>.</param>
public EventScriptAttribute(string scriptName)
{
ScriptName = scriptName ?? throw new ArgumentNullException(nameof(scriptName));
}
}
}
@@ -1,4 +1,4 @@
namespace Tgstation.Server.Host.Components
namespace Tgstation.Server.Host.Components.Events
{
/// <summary>
/// Types of events. Mirror in tgs.dm
@@ -8,86 +8,103 @@
/// <summary>
/// Parameters: Reference name, commit sha
/// </summary>
[EventScript("RepoResetOrigin")]
RepoResetOrigin,
/// <summary>
/// Parameters: Checkout target
/// </summary>
[EventScript("RepoCheckout")]
RepoCheckout,
/// <summary>
/// No parameters
/// </summary>
[EventScript("RepoFetch")]
RepoFetch,
/// <summary>
/// Parameters: Pull request number, pull request sha, merger message
/// </summary>
[EventScript("RepoMergePullRequest")]
RepoMergePullRequest,
/// <summary>
/// Parameters: Absolute path to repository root
/// </summary>
[EventScript("PreSynchronize")]
RepoPreSynchronize,
/// <summary>
/// Parameters: Version being installed
/// </summary>
[EventScript("ByondInstallStart")]
ByondInstallStart,
/// <summary>
/// Parameters: Error string
/// </summary>
[EventScript("ByondInstallFail")]
ByondInstallFail,
/// <summary>
/// Parameters: Old active version, new active version
/// </summary>
[EventScript("ByondActiveVersionChange")]
ByondActiveVersionChange,
/// <summary>
/// Parameters: Game directory path, origin commit sha
/// </summary>
[EventScript("PreCompile")]
CompileStart,
/// <summary>
/// No parameters
/// </summary>
[EventScript("CompileCancelled")]
CompileCancelled,
/// <summary>
/// Parameters: Game directory path, "1" if compile succeeded and api validation failed, "0" otherwise
/// </summary>
[EventScript("CompileFailure")]
CompileFailure,
/// <summary>
/// Parameters: Game directory path
/// </summary>
[EventScript("PostCompile")]
CompileComplete,
/// <summary>
/// No parameters
/// </summary>
[EventScript("InstanceAutoUpdateStart")]
InstanceAutoUpdateStart,
/// <summary>
/// Parameters: Base sha, target sha, base reference, target reference
/// </summary>
[EventScript("RepoMergeConflict")]
RepoMergeConflict,
/// <summary>
/// No parameters
/// </summary>
[EventScript("DeploymentComplete")]
DeploymentComplete,
/// <summary>
/// Before the watchdog shutsdown. Not sent for graceful shutdowns. No parameters.
/// </summary>
[EventScript("WatchdogShutdown")]
WatchdogShutdown,
/// <summary>
/// Before the watchdog detaches. No parameters.
/// </summary>
[EventScript("WatchdogDetach")]
WatchdogDetach,
}
}
@@ -2,7 +2,7 @@
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Components
namespace Tgstation.Server.Host.Components.Events
{
/// <summary>
/// Consumes <see cref="EventType"/>s and takes the appropriate actions
@@ -0,0 +1,8 @@
# Event System
Many actions in TGS generate events. They are dispatched via the [IEventConsumer](./IEventConsumer.cs) ([main implementation](./EventConsumer.cs)). From there, it is further sent to the two actual consumers.
1. The static files system will attempt to run a script with the given [EventScriptAttribute](./EventScriptAttribute)'s `ScriptName`.
1. The watchdog will send a mirror of the event via the DMAPI to be handled by game code.
With these two endpoints, the behaviour of TGS is highly customizable as server operators can customize the entire process.
@@ -9,6 +9,7 @@ using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components.Byond;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Deployment;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Components.Watchdog;
using Tgstation.Server.Host.Database;
@@ -150,6 +151,7 @@ namespace Tgstation.Server.Host.Components
#pragma warning disable CA1502 // TODO: Decomplexify
async Task TimerLoop(uint minutes, CancellationToken cancellationToken)
{
logger.LogTrace("Entering auto-update loop");
while (true)
try
{
@@ -233,7 +235,7 @@ namespace Tgstation.Server.Host.Components
.AsQueryable()
.Where(x => x.CommitSha == startSha && x.Instance.Id == metadata.Id)
.Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge)
.FirstOrDefaultAsync(cancellationToken);
.FirstOrDefaultAsync(jobCancellationToken);
async Task UpdateRevInfo(string currentHead, bool onOrigin)
{
@@ -300,11 +302,12 @@ namespace Tgstation.Server.Host.Components
var currentHead = repo.Head;
currentRevInfo = await databaseContext.RevisionInformations
.AsQueryable()
.Where(x => x.CommitSha == currentHead && x.Instance.Id == metadata.Id)
.FirstOrDefaultAsync(jobCancellationToken).ConfigureAwait(false);
.AsQueryable()
.Where(x => x.CommitSha == currentHead && x.Instance.Id == metadata.Id)
.FirstOrDefaultAsync(jobCancellationToken)
.ConfigureAwait(false);
if (currentHead != startSha && currentRevInfo != default)
if (currentHead != startSha && currentRevInfo == default)
await UpdateRevInfo(currentHead, true).ConfigureAwait(false);
shouldSyncTracked = true;
@@ -324,7 +327,7 @@ namespace Tgstation.Server.Host.Components
if (hasDbChanges)
try
{
await databaseContext.Save(cancellationToken).ConfigureAwait(false);
await databaseContext.Save(jobCancellationToken).ConfigureAwait(false);
}
catch
{
@@ -367,7 +370,7 @@ namespace Tgstation.Server.Host.Components
DreamMaker.DeploymentProcess,
cancellationToken).ConfigureAwait(false);
await jobManager.WaitForJobCompletion(compileProcessJob, user, cancellationToken, default).ConfigureAwait(false);
await jobManager.WaitForJobCompletion(compileProcessJob, user, default, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -7,6 +7,7 @@ using Tgstation.Server.Host.Components.Byond;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Chat.Commands;
using Tgstation.Server.Host.Components.Deployment;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Components.Interop.Bridge;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Components.Session;
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using Tgstation.Server.Host.Components.Events;
namespace Tgstation.Server.Host.Components.Interop.Topic
{
@@ -10,6 +10,7 @@ Component code is where the magic and tears of TGS are made. There are six main
There exist two more namespaces in here that don't directly fit in these 6 components.
- [Events](./Events) deals with the TGS event system.
- [Interop](./Interop) deals with the bulk of DMAPI communication (Though it's not all contained here).
- [Session](./Session) contains the classes used for actually executing DreamDaemon, sending topic requests, receiving bridge requests, among other things.
@@ -19,4 +20,6 @@ While the database represents stored instance data, in component code an instanc
`IInstance`s are created via the [IInstanceFactory](./IInstanceFactory.cs) ([implementation](./InstanceFactory.cs)) and are generally controlled via the [IInstanceManager](./IInstanceManager.cs) ([implementation](./InstanceManager.cs)).
Many classes in here implement [IHostedService](), `InstanceManager` being the only one that is called by the ASP.NET runtime. In the case of instances `StartAsync()` is called when an `Instance` is being brought online (from server startup or user request). The `Instance` handles calling `StartAsync()` on its various subcomponents that need it. When an `Instance` is being brought offline (from server shutdown/restart/update or user request) the same pattern is followed calling `StopAsync()`.
Many classes in here implement [IHostedService](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&tabs=visual-studio), `InstanceManager` being the only one that is called by the ASP.NET runtime. In the case of instances `StartAsync()` is called when an `Instance` is being brought online (from server startup or user request). The `Instance` handles calling `StartAsync()` on its various subcomponents that need it. When an `Instance` is being brought offline (from server shutdown/restart/update or user request) the same pattern is followed calling `StopAsync()`.
`IInstanceManager` is the sole point where the controllers talk to component code. It also dispatches bridge requests to their relevant instances.
@@ -8,6 +8,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -4,6 +4,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -0,0 +1,14 @@
# Session Management
"Session" refers to a single invocation of DreamDaemon. The code in here is meant for creating and managing sessions. This is different from the watchdog as it is more low level.
[ISessionController](./ISessionController.cs) ([implementation](SessionController.cs)) is the representation of DreamDaemon. It handles most of the DMAPI interop, such as DMAPI validation, and raising events for things such as the world rebooting, or the process ending. These are created by the [ISessionControllerFactory](./ISessionControllerFactory.cs) ([implementation](./SessionControllerFactory.cs)) which handles actually launching DreamDaemon and linking together all the components it needs. It also runs some sanity checks, such as if the port requested is avaiable or if the BYOND pager is running (which can cause issues). Sessions give out a [LaunchResult](./LaunchResult.cs) which indicates how long it took for DreamDaemon to become responsive or if it exited before it did.
Sessions can be detached and reattached. When a session is detached, it generates [ReattachInformation](./ReattachInformation.cs). This is stored in and the database as [DualReattachInformation](./DualReattachInformation.cs) by passing it into and out of [IReattachInfoHandler](./IReattachInfoHandler.cs) ([implementation](./ReattachInfoHandler.cs)). This data can be passed back into the `ISessionControllerFactory` to reattach the session.
Other classes:
- [ApiValidationStatus](./ApiValidationStatus.cs) is an indicator of DMAPI validation.
- [CombinedTopicResponse](./CombinedTopicResponse.cs) is a wrapper class around the external [BYOND.TopicSender] library's raw topic response (i.e. string or float + raw bytes) and our internal interop response.
- [DeadSessionController](./DeadSessionController.cs) dummy implementation of `ISessionController`.
- [RebootState](./RebootState.cs) indicates the action that should be taken when the server's world reboots. Just an indicator though, action is take elsewhere.
@@ -241,8 +241,9 @@ namespace Tgstation.Server.Host.Components.Session
_ = process.Lifetime.ContinueWith(
x =>
{
if (!disposed)
reattachTopicCts.Cancel();
lock (synchronizationLock)
if (!disposed)
reattachTopicCts.Cancel();
chatTrackingContext.Active = false;
},
TaskScheduler.Current);
@@ -280,6 +281,8 @@ namespace Tgstation.Server.Host.Components.Session
{
if (disposed)
return;
disposed = true;
logger.LogTrace("Disposing...");
if (disposing)
{
if (!released)
@@ -290,10 +293,9 @@ namespace Tgstation.Server.Host.Components.Session
process.Dispose();
bridgeRegistration.Dispose();
Dmb?.Dispose(); // will be null when released
reattachInformation.Dmb?.Dispose(); // will be null when released
chatTrackingContext.Dispose();
reattachTopicCts.Dispose();
disposed = true;
}
else
{
@@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
@@ -233,12 +234,18 @@ namespace Tgstation.Server.Host.Components.Session
var visibility = apiValidate ? "invisible" : "public";
// important to run on all ports to allow port changing
var arguments = String.Format(CultureInfo.InvariantCulture, "{0} -port {1} -ports 1-65535 {2}-close -{3} -{4} -public -params \"{5}\"",
Guid? logFileGuid = null;
var arguments = String.Format(
CultureInfo.InvariantCulture,
"{0} -port {1} -ports 1-65535 {2}-close -{3} -{4}{5} -public -params \"{6}\"",
dmbProvider.DmbName,
portToUse,
launchParameters.AllowWebClient.Value ? "-webclient " : String.Empty,
SecurityWord(launchParameters.SecurityLevel.Value),
visibility,
platformIdentifier.IsWindows
? $" -log {logFileGuid = Guid.NewGuid()}"
: String.Empty, // Just use stdout on linux
parameters);
// See https://github.com/tgstation/tgstation-server/issues/719
@@ -253,16 +260,52 @@ namespace Tgstation.Server.Host.Components.Session
noShellExecute,
noShellExecute: noShellExecute);
if (noShellExecute)
async Task<string> GetDDOutput()
{
// Log DD output
_ = process.Lifetime.ContinueWith(
x => logger.LogTrace(
"DreamDaemon Output:{0}{1}",
Environment.NewLine, process.GetCombinedOutput()),
TaskScheduler.Current);
if (!platformIdentifier.IsWindows)
return process.GetCombinedOutput();
var logFilePath = ioManager.ConcatPath(basePath, logFileGuid.ToString());
try
{
var dreamDaemonLogBytes = await ioManager.ReadAllBytes(
logFilePath,
default)
.ConfigureAwait(false);
return Encoding.UTF8.GetString(dreamDaemonLogBytes);
}
finally
{
try
{
await ioManager.DeleteFile(logFilePath, default).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning("Failed to delete DreamDaemon log file {0}: {1}", logFilePath, ex);
}
}
}
// Log DD output
_ = process.Lifetime.ContinueWith(
async x =>
{
try
{
var ddOutput = await GetDDOutput().ConfigureAwait(false);
logger.LogTrace(
"DreamDaemon Output:{0}{1}",
Environment.NewLine, ddOutput);
}
catch (Exception ex)
{
logger.LogWarning("Error reading DreamDaemon output: {0}", ex);
}
},
TaskScheduler.Current);
try
{
networkPromptReaper.RegisterProcess(process);
@@ -9,6 +9,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -32,12 +33,18 @@ namespace Tgstation.Server.Host.Components.StaticFiles
const string CodeModificationsHeadFile = "HeadInclude.dm";
const string CodeModificationsTailFile = "TailInclude.dm";
static readonly IReadOnlyDictionary<EventType, string> EventTypeScriptFileNameMap = new Dictionary<EventType, string>
{
{ EventType.CompileStart, "PreCompile" },
{ EventType.CompileComplete, "PostCompile" },
{ EventType.RepoPreSynchronize, "PreSynchronize" }
};
static readonly IReadOnlyDictionary<EventType, string> EventTypeScriptFileNameMap = new Dictionary<EventType, string>(
Enum.GetValues(typeof(EventType))
.OfType<EventType>()
.Select(
eventType => new KeyValuePair<EventType, string>(
eventType,
typeof(EventType)
.GetField(eventType.ToString())
.GetCustomAttributes(false)
.OfType<EventScriptAttribute>()
.First()
.ScriptName)));
/// <summary>
/// The <see cref="IIOManager"/> for <see cref="Configuration"/>
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Components.StaticFiles
@@ -328,5 +328,23 @@ namespace Tgstation.Server.Host.Components.Watchdog
return Restart(true, cancellationToken);
}
/// <summary>
/// Send a chat message and log about a failed reattach operation and attempts another call to <see cref="WatchdogBase.LaunchNoLock(bool, bool, DualReattachInformation, CancellationToken)"/>.
/// </summary>
/// <param name="inactiveReattachSuccess">If the inactive server was reattached successfully.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation/</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task NotifyOfFailedReattach(bool inactiveReattachSuccess, CancellationToken cancellationToken)
{
// we lost the server, just restart entirely
DisposeAndNullControllers();
const string FailReattachMessage = "Unable to properly reattach to server! Restarting...";
Logger.LogWarning(FailReattachMessage);
Logger.LogDebug(inactiveReattachSuccess ? "Also could not reattach to inactive server!" : "Inactive server was reattached successfully!");
Task chatTask = Chat.SendWatchdogMessage(FailReattachMessage, false, cancellationToken);
await LaunchNoLock(true, false, null, cancellationToken).ConfigureAwait(false);
await chatTask.ConfigureAwait(false);
}
}
}
@@ -512,7 +512,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
Logger.LogDebug(bothServersDead ? "Also could not reattach to inactive server!" : "Inactive server was reattached successfully!");
chatTask = Chat.SendWatchdogMessage(FailReattachMessage, false, cancellationToken);
callBeforeRecurse();
await LaunchImplNoLock(true, false, null, cancellationToken).ConfigureAwait(false);
await LaunchNoLock(true, false, null, cancellationToken).ConfigureAwait(false);
await chatTask.ConfigureAwait(false);
return;
}
@@ -3,6 +3,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Components.Session;
namespace Tgstation.Server.Host.Components.Watchdog
@@ -0,0 +1,52 @@
# The Watchdog
The watchdog is what keeps DreamDaemon alive and well. It's responsible for starting, stopping, rebooting it, sending TGS events to the DMAPI, running heartbeats, and applying patches.
There are three main implementations of the interface [IWatchdog](./IWatchdog.cs) that deal with how it handles the last of those jobs.
- The [BasicWatchdog](./BasicWatchdog.cs) kills DreamDaemon when the world reboots and immediately restarts it with the newly compiled .dmb.
- The [WindowsWatchdog](./WindowsWatchdog.cs) uses a quirk of the interaction of DD with the Windows file system. A symlink to the game folder is created. From here, we launch the game. When a new .dmb is available, this symlink is deleted and then immediately recreated pointing to the new game directory. When DreamDaemon reboots it will load the new game.
- The [ExperimentalWatchdog](./ExperimentalWatchdog) starts new servers when the new .dmb comes in. But it uses some, rather invasive, port trickery to reroute traffic from the old game server to the new one. The idea for this is to always have two servers running so one can act as a fallback for if the main one fails. This is currently a big work-in-progress as it has issues with BYOND bugs and static file sharing.
[WatchdogBase](./WatchdogBase.cs) is the parent of all these implementations and contains the bulk of the monitoring code. More on that later.
`IWatchdog`s are created via the [IWatchdogFactory](./IWatchdogFactory.cs) interface. The two implementations, [WatchdogFactory](./WatchdogFactory.cs) and [WindowsWatchdogFactory](./WindowsWatchdogFactory.cs), differ in that the latter creates `WindowsWatchdog`s as opposed to `BasicWatchdog`s.
## Startup
From the perspective of `WatchdogBase`
- When an instance is onlined, `StartAsync()` is called on `WatchdogBase`. If the instance is configured to autostart the server, or if reattach information is available from the `IReattachInfoHandler` the watchdog will be launched here. Otherwise, it waits to be activated from the controller.
- We first do some sanity checks such as if the watchdog is already running and if the `IDmbFactory` has a IDmbProvider for us.
- We send out the annoucement of launch/reattach to the chat system.
- The `ActiveLaunchParameters` are made the `LastLaunchParameters` for reference.
- `InitControllers()` is called, which abstractly creates the `ISessionController`(s) for the watchdog.
- For the `BasicWatchdog`, the controller is created normally.
- For the `WindowsWatchdog`, we replace the `IDmbProvider` with a `WindowsSwappableDmbProvider` and setup the symlinking before deferring to the `BasicWatchdog`
- For the `ExperimentalWatchdog` we launch two servers in tandem.
- For reattaching, all three work similarly in that they use `ISessionControllerFactory` to reattach their sessions. The `ExperimentalWatchdog` will insert a `DeadSessionController` in place of one if only one server could not reattach.
- `MonitorLifetimes()` is called which is the "main loop" of the watchdog.
## Monitoring
Watchdog monitoring takes place in the `MonitorLifetimes()` functiona and is entirely event based. It responds to a set of [MonitorActivationReason](./MonitorActivationReason.cs)s and takes the apporprate action. This includes:
- When a server crashes.
- When a server calls `/world/prod/Reboot()`.
- When a new .dmb is deployed.
- When DreamDaemon settings are changed via the API.
- When it is time to make a heartbeat.
Here's how this works.
- A [MonitorState](./MonitorState.cs) object is created
- `Task`s are created for all possible activation reasons and an asynchrounous wait is made on them.
- Each activation reason is processed in a particular order via a call to `HandleMonitorWakeup()`. This function is allowed to mutate `MonitorState`.
- How each activation reason is handled depends on the watchdog type. For example, the `BasicWatchdog` will queue a graceful reboot when a new .dmb becomes available, wheras the `WindowsWatchdog` will immediately swap the symlink to it.
- Some actions are universal. i.e. if the server crashes, a reboot will occur. Or the server will shutdown on reboot if a graceful stop was queued.
- If the state's [MonitorAction](./MonitorAction.cs) changes, what happens after each activation may change
This continues until `StopMonitor` is called, which triggers the `CancellationToken` in `MonitorLifetimes()` and terminates all `ISessionController`s.
## Detaching
`WatchdogBase` maintains a `IRestartRegistration` with TGS, meaning that, when TGS is rebooted, `HandleRestart()` will be called before `StopAsync()`. This has the effect of adjusting what happens when the monitor exits. Instead of terminating the server(s) it will instead call `ISessionController.Release()` on them. This generates the `ReattachInformation` which will be saved to the database in `StopAsync()`.
@@ -11,6 +11,7 @@ using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Deployment;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.Components.Interop.Topic;
using Tgstation.Server.Host.Components.Session;
using Tgstation.Server.Host.Core;
@@ -118,7 +119,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
readonly object controllerDisposeLock;
/// <summary>
/// If the <see cref="WatchdogBase"/> should <see cref="LaunchImplNoLock(bool, bool, DualReattachInformation, CancellationToken)"/> in <see cref="StartAsync(CancellationToken)"/>
/// If the <see cref="WatchdogBase"/> should <see cref="LaunchNoLock(bool, bool, DualReattachInformation, CancellationToken)"/> in <see cref="StartAsync(CancellationToken)"/>
/// </summary>
readonly bool autoStart;
@@ -331,14 +332,14 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="reattachInfo"><see cref="DualReattachInformation"/> to use, if any</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
protected async Task LaunchImplNoLock(bool startMonitor, bool announce, DualReattachInformation reattachInfo, CancellationToken cancellationToken)
protected async Task LaunchNoLock(bool startMonitor, bool announce, DualReattachInformation reattachInfo, CancellationToken cancellationToken)
{
Logger.LogTrace("Begin LaunchImplNoLock");
if (Running)
throw new JobException(ErrorCode.WatchdogRunning);
if (!DmbFactory.DmbAvailable)
if (reattachInfo == null && !DmbFactory.DmbAvailable)
throw new JobException(ErrorCode.WatchdogCompileJobCorrupted);
// this is necessary, the monitor could be in it's sleep loop trying to restart, if so cancel THAT monitor and start our own with blackjack and hookers
@@ -355,10 +356,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
heartbeatsMissed = 0;
// for when we call ourself and want to not catch thrown exceptions
var ignoreNestedException = false;
var recursiveCallToHappen = false;
try
{
await InitControllers(() => ignoreNestedException = true, chatTask, reattachInfo, cancellationToken).ConfigureAwait(false);
await InitControllers(() => recursiveCallToHappen = true, chatTask, reattachInfo, cancellationToken).ConfigureAwait(false);
if (recursiveCallToHappen)
return;
await chatTask.ConfigureAwait(false);
Logger.LogInformation("Launched servers successfully");
@@ -366,13 +370,14 @@ namespace Tgstation.Server.Host.Components.Watchdog
if (startMonitor)
{
StartMonitor();
monitorCts = new CancellationTokenSource();
monitorTask = MonitorLifetimes(monitorCts.Token);
}
}
catch (Exception e)
{
// don't try to send chat tasks or warning logs if were suppressing exceptions or cancelled
if (!ignoreNestedException && !cancellationToken.IsCancellationRequested)
if (!recursiveCallToHappen && !cancellationToken.IsCancellationRequested)
{
var originalChatTask = chatTask;
async Task ChainChatTaskWithErrorMessage()
@@ -398,15 +403,6 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
}
/// <summary>
/// Call <see cref="MonitorLifetimes(CancellationToken)"/> and setup <see cref="monitorCts"/> and <see cref="monitorTask"/>.
/// </summary>
protected void StartMonitor()
{
monitorCts = new CancellationTokenSource();
monitorTask = MonitorLifetimes(monitorCts.Token);
}
/// <summary>
/// Stops <see cref="MonitorLifetimes(CancellationToken)"/>. Doesn't kill the servers
/// </summary>
@@ -426,24 +422,6 @@ namespace Tgstation.Server.Host.Components.Watchdog
return wasRunning;
}
/// <summary>
/// Send a chat message and log about a failed reattach operation and attempts another call to <see cref="LaunchImplNoLock(bool, bool, DualReattachInformation, CancellationToken)"/>.
/// </summary>
/// <param name="inactiveReattachSuccess">If the inactive server was reattached successfully.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation/</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
protected async Task NotifyOfFailedReattach(bool inactiveReattachSuccess, CancellationToken cancellationToken)
{
// we lost the server, just restart entirely
DisposeAndNullControllers();
const string FailReattachMessage = "Unable to properly reattach to server! Restarting...";
Logger.LogWarning(FailReattachMessage);
Logger.LogDebug(inactiveReattachSuccess ? "Also could not reattach to inactive server!" : "Inactive server was reattached successfully!");
Task chatTask = Chat.SendWatchdogMessage(FailReattachMessage, false, cancellationToken);
await LaunchImplNoLock(true, false, null, cancellationToken).ConfigureAwait(false);
await chatTask.ConfigureAwait(false);
}
/// <summary>
/// Check the <see cref="LaunchResult"/> of a given <paramref name="controller"/> for errors and throw a <see cref="JobException"/> if any are detected.
/// </summary>
@@ -476,6 +454,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
protected void DisposeAndNullControllers()
{
Logger.LogTrace("DisposeAndNullControllers");
lock (controllerDisposeLock)
DisposeAndNullControllersImpl();
}
@@ -512,6 +491,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
MonitorState monitorState,
CancellationToken cancellationToken);
/// <summary>
/// Attempt to restart the monitor from scratch.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="MonitorState"/>.</returns>
private async Task<MonitorState> MonitorRestart(CancellationToken cancellationToken)
{
Logger.LogTrace("Monitor restart!");
@@ -525,7 +509,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
try
{
// use LaunchImplNoLock without announcements or restarting the monitor
await LaunchImplNoLock(false, false, null, cancellationToken).ConfigureAwait(false);
await LaunchNoLock(false, false, null, cancellationToken).ConfigureAwait(false);
if (Running)
{
Logger.LogDebug("Relaunch successful, resetting monitor state...");
@@ -567,7 +551,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
/// <summary>
/// The loop that watches the watchdog.
/// The main loop of the watchdog. Ayschronously waits for events to occur and then responds to them.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
@@ -606,35 +590,32 @@ namespace Tgstation.Server.Host.Components.Watchdog
// cancel waiting if requested
var cancelTcs = new TaskCompletionSource<object>();
var toWaitOn = Task.WhenAny(
activeServerLifetime,
activeServerReboot,
inactiveServerLifetime,
inactiveServerReboot,
inactiveStartupComplete,
heartbeat,
newDmbAvailable,
cancelTcs.Task,
activeLaunchParametersChanged);
// wait for something to happen
using (cancellationToken.Register(() => cancelTcs.SetCanceled()))
{
var toWaitOn = Task.WhenAny(
activeServerLifetime,
activeServerReboot,
inactiveServerLifetime,
inactiveServerReboot,
inactiveStartupComplete,
heartbeat,
newDmbAvailable,
cancelTcs.Task,
activeLaunchParametersChanged);
// wait for something to happen
await toWaitOn.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
}
cancellationToken.ThrowIfCancellationRequested();
Logger.LogTrace("Monitor activated");
// always run HandleMonitorWakeup from the context of the semaphore lock
using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false))
{
// always run HandleMonitorWakeup from the context of the semaphore lock
// multiple things may have happened, handle them one at a time
for (var moreActivationsToProcess = true; moreActivationsToProcess && (monitorState.NextAction == MonitorAction.Continue || monitorState.NextAction == MonitorAction.Skip);)
{
MonitorActivationReason activationReason = default; // this will always be assigned before being used
// process the tasks in this order and call HandlerMonitorWakup for each
bool CheckActivationReason(ref Task task, MonitorActivationReason testActivationReason)
{
var taskCompleted = task?.IsCompleted == true;
@@ -650,6 +631,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
return false;
}
// process the tasks in this order and call HandlerMonitorWakup for each depending on the new monitorState
var anyActivation = CheckActivationReason(ref activeServerLifetime, MonitorActivationReason.ActiveServerCrashed)
|| CheckActivationReason(ref activeServerReboot, MonitorActivationReason.ActiveServerRebooted)
|| CheckActivationReason(ref newDmbAvailable, MonitorActivationReason.NewDmbAvailable)
@@ -680,11 +662,14 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
Logger.LogTrace("Next monitor action is to {0}", monitorState.NextAction);
// Restart if requested
if (monitorState.NextAction == MonitorAction.Restart)
monitorState = await MonitorRestart(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// let this bubble, other exceptions caught below
throw;
}
catch (Exception e)
@@ -711,17 +696,18 @@ namespace Tgstation.Server.Host.Components.Watchdog
await chatTask.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
Logger.LogDebug("Monitor cancelled");
}
catch (OperationCanceledException)
{
// stop signal
Logger.LogDebug("Monitor cancelled");
if (releaseServers)
{
Logger.LogTrace("Detaching servers...");
releasedReattachInformation = CreateReattachInformation();
}
if (releaseServers)
{
Logger.LogTrace("Detaching servers...");
releasedReattachInformation = CreateReattachInformation();
}
}
DisposeAndNullControllers();
@@ -731,7 +717,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// Starts all <see cref="ISessionController"/>s.
/// </summary>
/// <param name="callBeforeRecurse">An <see cref="Action"/> that must be run before making a recursive call to <see cref="LaunchImplNoLock(bool, bool, DualReattachInformation, CancellationToken)"/>.</param>
/// <param name="callBeforeRecurse">An <see cref="Action"/> that must be run before making a recursive call to <see cref="LaunchNoLock(bool, bool, DualReattachInformation, CancellationToken)"/>.</param>
/// <param name="chatTask">A, possibly active, <see cref="Task"/> for an outgoing chat message.</param>
/// <param name="reattachInfo"><see cref="DualReattachInformation"/> to use, if any</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
@@ -743,11 +729,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
{
using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false))
{
if (launchParameters.Match(ActiveLaunchParameters))
return;
bool match = launchParameters.CanApplyWithoutReboot(ActiveLaunchParameters);
ActiveLaunchParameters = launchParameters;
if (Running)
ActiveParametersUpdated.TrySetResult(null); // queue an update
if (match || !Running)
return;
ActiveParametersUpdated.TrySetResult(null); // queue an update
ActiveParametersUpdated = new TaskCompletionSource<object>();
}
}
@@ -815,7 +803,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
public async Task Launch(CancellationToken cancellationToken)
{
using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false))
await LaunchImplNoLock(true, true, null, cancellationToken).ConfigureAwait(false);
await LaunchNoLock(true, true, null, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -834,10 +822,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <inheritdoc />
public async Task Restart(bool graceful, CancellationToken cancellationToken)
{
if (!Running)
throw new JobException(ErrorCode.WatchdogRunning);
Logger.LogTrace("Begin Restart. Graceful: {0}", graceful);
using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false))
{
if (!graceful || !Running)
if (!graceful)
{
Task chatTask;
if (Running)
@@ -847,16 +838,14 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
else
chatTask = Task.CompletedTask;
await LaunchImplNoLock(true, !Running, null, cancellationToken).ConfigureAwait(false);
await LaunchNoLock(true, !Running, null, cancellationToken).ConfigureAwait(false);
await chatTask.ConfigureAwait(false);
}
var toReboot = GetActiveController();
if (toReboot != null)
{
if (!await toReboot.SetRebootState(Session.RebootState.Restart, cancellationToken).ConfigureAwait(false))
Logger.LogWarning("Unable to send reboot state change event!");
}
if (toReboot != null
&& !await toReboot.SetRebootState(Session.RebootState.Restart, cancellationToken).ConfigureAwait(false))
Logger.LogWarning("Unable to send reboot state change event!");
}
}
@@ -895,21 +884,20 @@ namespace Tgstation.Server.Host.Components.Watchdog
await jobManager.RegisterOperation(job, async (j, databaseContextFactory, progressFunction, ct) =>
{
using (await SemaphoreSlimContext.Lock(Semaphore, ct).ConfigureAwait(false))
await LaunchImplNoLock(true, true, reattachInfo, ct).ConfigureAwait(false);
await LaunchNoLock(true, true, reattachInfo, ct).ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
if (releaseServers)
{
await StopMonitor().ConfigureAwait(false);
if (releasedReattachInformation != null)
await reattachInfoHandler.Save(releasedReattachInformation, cancellationToken).ConfigureAwait(false);
}
await TerminateNoLock(false, !releaseServers, cancellationToken).ConfigureAwait(false);
if (releasedReattachInformation != null)
{
await reattachInfoHandler.Save(releasedReattachInformation, cancellationToken).ConfigureAwait(false);
releasedReattachInformation = null;
releaseServers = false;
}
}
/// <inheritdoc />
@@ -117,8 +117,6 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <inheritdoc />
protected override MonitorAction HandleNormalReboot()
{
if (activeSwappable == null)
throw new InvalidOperationException("Expected activeSwappable to not be null!");
if (pendingSwappable != null)
{
Logger.LogTrace("Replacing activeSwappable with pendingSwappable...");
@@ -357,8 +357,6 @@ namespace Tgstation.Server.Host.Core
// Final point where we wrap exceptions in a 500 (ErrorMessage) response
applicationBuilder.UseServerErrorHandling();
applicationBuilder.UseRequestCounting();
// 503 requests made while the application is starting
applicationBuilder.UseAsyncInitialization(async (cancellationToken) =>
{
@@ -107,32 +107,5 @@ namespace Tgstation.Server.Host.Extensions
}
});
}
/// <summary>
/// Add middleware for logging the request number.
/// </summary>
/// <param name="applicationBuilder">The <see cref="IApplicationBuilder"/> to configure.</param>
public static void UseRequestCounting(this IApplicationBuilder applicationBuilder)
{
if (applicationBuilder == null)
throw new ArgumentNullException(nameof(applicationBuilder));
ulong requestCounter = 0;
applicationBuilder.Use(async (context, next) =>
{
var logger = GetLogger(context);
var requestNumber = ++requestCounter;
logger.LogTrace("Starting request #{0}...", requestNumber);
try
{
await next().ConfigureAwait(false);
}
finally
{
logger.LogTrace("Finished request #{0}", requestNumber);
}
});
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
# Jobs Subsystem
- [IJobManager](./IJobManager.cs) and [implementation](./JobManager.cs) is where the bulk of the magic happens. The `RegisterOperation()` call is what takes a work unit and sets it to run asynchronously while being tracked through the API.
- [JobException] is a special .NET Exception implementation that is able to carry API `ErrorCode`s and other additional data.
- [JobException](./JobException.cs) is a special .NET Exception implementation that is able to carry API `ErrorCode`s and other additional data.
- [JobHandler](./JobHandler.cs) carries the [CancellationTokenSource](https://stackoverflow.com/questions/20638952/cancellationtoken-and-cancellationtokensource-how-to-use-it) for a given job in a disposable context.
@@ -0,0 +1,21 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
namespace Tgstation.Server.Host.Components.Events.Tests
{
/// <summary>
/// Tests for the <see cref="EventScriptAttribute"/> <see langword="class"/>.
/// </summary>
[TestClass]
public sealed class TestEventScriptAttribute
{
[TestMethod]
public void TestConstruction()
{
Assert.ThrowsException<ArgumentNullException>(() => new EventScriptAttribute(null));
var test = new EventScriptAttribute("test");
Assert.AreEqual("test", test.ScriptName);
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Tgstation.Server.Host.Components.Events.Tests
{
[TestClass]
public sealed class TestEventType
{
[TestMethod]
public void TestAllEventTypesHaveUniqueEventScriptAttributes()
{
var allScripts = new HashSet<string>();
foreach (var eventType in Enum.GetValues(typeof(EventType))) {
var list = typeof(EventType)
.GetField(eventType.ToString())
.GetCustomAttributes(false)
.OfType<EventScriptAttribute>()
.ToList();
Assert.AreEqual(1, list.Count, $"EventType: {eventType}");
var attribute = list.First();
Assert.IsTrue(allScripts.Add(attribute.ScriptName), $"Non-unique script Name: {attribute.ScriptName}");
}
}
}
}