Implement basic crash protection

- On start, we create/load crash file, if previous startup was less than 5 minutes ago, we also increase the counter
- If counter reaches 5, we abort the process, that is, freeze the process by not loading anything other than auto-updates
- In order for user to recover from above, he needs to manually remove ASF.crash file
- If process exits normally without reaching counter of 5 yet, we remove crash file (reset the counter), normal exit includes SIGTERM, SIGINT, clicking [X], !exit, !restart, and anything else that allows ASF to gracefully quit
- If process exits abnormally, that includes SIGKILL, unhandled exceptions, as well as power outages and anything that prevents ASF from graceful quit, we keep crash file around
- Update procedure, as an exception, allows crash file removal even with counter of 5. This should allow crash file recovery for people that crashed before, without a need of manual removal.
This commit is contained in:
Archi
2024-02-03 21:18:47 +01:00
parent eb71b640c5
commit 4a0c5d2203
7 changed files with 199 additions and 14 deletions

View File

@@ -101,6 +101,7 @@ public static class ASF {
return fileType switch {
EFileType.Config => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalConfigFileName),
EFileType.Database => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalDatabaseFileName),
EFileType.Crash => Path.Combine(SharedInfo.ConfigDirectory, SharedInfo.GlobalCrashFileName),
_ => throw new InvalidOperationException(nameof(fileType))
};
}
@@ -110,14 +111,22 @@ public static class ASF {
throw new InvalidOperationException(nameof(GlobalConfig));
}
if (!await PluginsCore.InitPlugins().ConfigureAwait(false)) {
return false;
}
WebBrowser = new WebBrowser(ArchiLogger, GlobalConfig.WebProxy, true);
await UpdateAndRestart().ConfigureAwait(false);
if (!Program.IgnoreUnsupportedEnvironment && !await ProtectAgainstCrashes().ConfigureAwait(false)) {
ArchiLogger.LogGenericError(Strings.ErrorTooManyCrashes);
return true;
}
Program.AllowCrashFileRemoval = true;
if (!await PluginsCore.InitPlugins().ConfigureAwait(false)) {
return false;
}
await PluginsCore.OnASFInitModules(GlobalConfig.AdditionalProperties).ConfigureAwait(false);
await InitRateLimiters().ConfigureAwait(false);
@@ -815,6 +824,32 @@ public static class ASF {
}
}
private static async Task<bool> ProtectAgainstCrashes() {
string crashFilePath = GetFilePath(EFileType.Crash);
CrashFile crashFile = await CrashFile.CreateOrLoad(crashFilePath).ConfigureAwait(false);
if (crashFile.StartupCount >= WebBrowser.MaxTries) {
// We've reached maximum allowed count of recent crashes, return failure
return false;
}
DateTime now = DateTime.UtcNow;
if (now - crashFile.LastStartup > TimeSpan.FromMinutes(5)) {
// Last crash was long ago, restart counter
crashFile.StartupCount = 1;
} else if (++crashFile.StartupCount >= WebBrowser.MaxTries) {
// We've reached maximum allowed count of recent crashes, return failure
return false;
}
crashFile.LastStartup = now;
// We're allowing this run to proceed
return true;
}
private static async Task RegisterBots() {
if (GlobalConfig == null) {
throw new InvalidOperationException(nameof(GlobalConfig));
@@ -907,6 +942,9 @@ public static class ASF {
return;
}
// Allow crash file recovery, if needed
Program.AllowCrashFileRemoval = true;
await RestartOrExit().ConfigureAwait(false);
}
@@ -1049,6 +1087,7 @@ public static class ASF {
internal enum EFileType : byte {
Config,
Database
Database,
Crash
}
}

View File

@@ -1226,5 +1226,11 @@ namespace ArchiSteamFarm.Localization {
return ResourceManager.GetString("WarningUnsupportedOfficialPlugins", resourceCulture);
}
}
public static string ErrorTooManyCrashes {
get {
return ResourceManager.GetString("ErrorTooManyCrashes", resourceCulture);
}
}
}
}

View File

@@ -90,7 +90,8 @@ StackTrace:
<comment>{0} will be replaced by function name, {1} will be replaced by exception message, {2} will be replaced by entire stack trace. Please note that this string should include newlines for formatting.</comment>
</data>
<data name="ErrorExitingWithNonZeroErrorCode" xml:space="preserve">
<value>Exiting with nonzero error code!</value>
<value>Exiting with {0} error code!</value>
<comment>{0} will be replaced by error code (number)</comment>
</data>
<data name="ErrorFailingRequest" xml:space="preserve">
<value>Request failing: {0}</value>
@@ -756,4 +757,7 @@ Process uptime: {1}</value>
<value>You're attempting to run official {0} plugin in mismatched with ASF version: {1} (expected {2}). This suggests you're doing something horribly wrong, either fix your setup or supply --ignore-unsupported-environment argument if you really know what you're doing.</value>
<comment>{0} will be replaced by plugin name, {1} will be replaced by plugin's version number, {2} will be replaced by ASF's version number.</comment>
</data>
<data name="ErrorTooManyCrashes" xml:space="preserve">
<value>Your ASF has crashed too many times recently, and due to that the process initialization has been disabled. Either investigate, fix your setup, then remove ASF.crash file from your config directory, or supply --ignore-unsupported-environment argument if you really know what you're doing.</value>
</data>
</root>

View File

@@ -151,6 +151,13 @@ public sealed class ArchiLogger {
Logger.Log(logEventInfo);
}
internal void LogFatalError(string message, [CallerMemberName] string? previousMethodName = null) {
ArgumentException.ThrowIfNullOrEmpty(message);
ArgumentException.ThrowIfNullOrEmpty(previousMethodName);
Logger.Fatal($"{previousMethodName}() {message}");
}
internal async Task LogFatalException(Exception exception, [CallerMemberName] string? previousMethodName = null) {
ArgumentNullException.ThrowIfNull(exception);
ArgumentException.ThrowIfNullOrEmpty(previousMethodName);

View File

@@ -47,6 +47,7 @@ using SteamKit2;
namespace ArchiSteamFarm;
internal static class Program {
internal static bool AllowCrashFileRemoval { get; set; }
internal static bool ConfigMigrate { get; private set; } = true;
internal static bool ConfigWatch { get; private set; } = true;
internal static bool IgnoreUnsupportedEnvironment { get; private set; }
@@ -67,7 +68,7 @@ internal static class Program {
internal static async Task Exit(byte exitCode = 0) {
if (exitCode != 0) {
ASF.ArchiLogger.LogGenericError(Strings.ErrorExitingWithNonZeroErrorCode);
ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorExitingWithNonZeroErrorCode, exitCode));
}
await Shutdown(exitCode).ConfigureAwait(false);
@@ -444,7 +445,7 @@ internal static class Program {
return true;
}
private static async Task<bool> InitShutdownSequence() {
private static async Task<bool> InitShutdownSequence(byte exitCode = 0) {
if (ShutdownSequenceInitialized) {
// We've already initialized shutdown sequence before, we won't allow the caller to init shutdown sequence again
// While normally this will be respected, caller might not have any say in this for example because it's the runtime terminating ASF due to fatal exception
@@ -456,8 +457,8 @@ internal static class Program {
ShutdownSequenceInitialized = true;
// Unregister from registered signals
if (OperatingSystem.IsFreeBSD() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) {
// Unregister from registered signals
foreach (PosixSignalRegistration registration in RegisteredPosixSignals.Values) {
registration.Dispose();
}
@@ -465,6 +466,19 @@ internal static class Program {
RegisteredPosixSignals.Clear();
}
// Remove crash file if allowed
if ((exitCode == 0) && AllowCrashFileRemoval) {
string crashFile = ASF.GetFilePath(ASF.EFileType.Crash);
if (File.Exists(crashFile)) {
try {
File.Delete(crashFile);
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
}
}
}
// Sockets created by IPC might still be running for a short while after complete app shutdown
// Ensure that IPC is stopped before we finalize shutdown sequence
await ArchiKestrel.Stop().ConfigureAwait(false);
@@ -497,16 +511,13 @@ internal static class Program {
return await ShutdownResetEvent.Task.ConfigureAwait(false);
}
private static async void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) => await Exit(130).ConfigureAwait(false);
private static async void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) => await Exit().ConfigureAwait(false);
private static async void OnPosixSignal(PosixSignalContext signal) {
ArgumentNullException.ThrowIfNull(signal);
switch (signal.Signal) {
case PosixSignal.SIGINT:
await Exit(130).ConfigureAwait(false);
break;
case PosixSignal.SIGTERM:
await Exit().ConfigureAwait(false);
@@ -704,7 +715,7 @@ internal static class Program {
}
private static async Task Shutdown(byte exitCode = 0) {
if (!await InitShutdownSequence().ConfigureAwait(false)) {
if (!await InitShutdownSequence(exitCode).ConfigureAwait(false)) {
return;
}

View File

@@ -48,6 +48,7 @@ public static class SharedInfo {
internal const string GithubReleaseURL = $"https://api.github.com/repos/{GithubRepo}/releases";
internal const string GithubRepo = $"JustArchiNET/{AssemblyName}";
internal const string GlobalConfigFileName = $"{ASF}{JsonConfigExtension}";
internal const string GlobalCrashFileName = $"{ASF}.crash";
internal const string GlobalDatabaseFileName = $"{ASF}{DatabaseExtension}";
internal const ushort InformationDelay = 10000;
internal const string IPCConfigExtension = ".config";

View File

@@ -0,0 +1,117 @@
// _ _ _ ____ _ _____
// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
// |
// Copyright 2015-2024 Łukasz "JustArchi" Domeradzki
// Contact: JustArchi@JustArchi.net
// |
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// |
// http://www.apache.org/licenses/LICENSE-2.0
// |
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System;
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using ArchiSteamFarm.Core;
using ArchiSteamFarm.Helpers;
using ArchiSteamFarm.Localization;
using JetBrains.Annotations;
using Newtonsoft.Json;
namespace ArchiSteamFarm.Storage;
internal sealed class CrashFile : SerializableFile {
internal DateTime LastStartup {
get => BackingLastStartup;
set {
if (BackingLastStartup == value) {
return;
}
BackingLastStartup = value;
Utilities.InBackground(Save);
}
}
internal byte StartupCount {
get => BackingStartupCount;
set {
if (BackingStartupCount == value) {
return;
}
BackingStartupCount = value;
Utilities.InBackground(Save);
}
}
[JsonProperty(Required = Required.DisallowNull)]
private DateTime BackingLastStartup;
[JsonProperty(Required = Required.DisallowNull)]
private byte BackingStartupCount;
private CrashFile(string filePath) : this() {
ArgumentException.ThrowIfNullOrEmpty(filePath);
FilePath = filePath;
}
[JsonConstructor]
private CrashFile() { }
[UsedImplicitly]
public bool ShouldSerializeBackingLastStartup() => BackingLastStartup > DateTime.MinValue;
[UsedImplicitly]
public bool ShouldSerializeBackingStartupCount() => BackingStartupCount > 0;
internal static async Task<CrashFile> CreateOrLoad(string filePath) {
ArgumentException.ThrowIfNullOrEmpty(filePath);
if (!File.Exists(filePath)) {
return new CrashFile(filePath);
}
CrashFile? crashFile;
try {
string json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
if (string.IsNullOrEmpty(json)) {
ASF.ArchiLogger.LogGenericError(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, nameof(json)));
return new CrashFile(filePath);
}
crashFile = JsonConvert.DeserializeObject<CrashFile>(json);
} catch (Exception e) {
ASF.ArchiLogger.LogGenericException(e);
return new CrashFile(filePath);
}
if (crashFile == null) {
ASF.ArchiLogger.LogNullError(crashFile);
return new CrashFile(filePath);
}
crashFile.FilePath = filePath;
return crashFile;
}
}