Skip to content

Commit

Permalink
Fix JSON serialization issue, improve error handling, and add HttpCli…
Browse files Browse the repository at this point in the history
…ent optimizations (#84, improvements)

This commit addresses several improvements and bug fixes for the ASF-FreeGames plugin:

* Fixed JSON serialization issue:
    * Resolved compatibility problems with recent ASF versions causing issues with `config.json` loading (`ASFFreeGamesOptionsSaver.cs`).
    * Implemented a new `SaveOptions` method that validates and writes configuration options to the file in a more robust way.
    * Added unit tests to ensure proper JSON serialization (`ASFFreeGamesOptionsSaverTests.cs`).
* Enhanced error handling:
    * Improved error message when encountering issues during `config.json` loading (`ASFFreeGames.cs`).
    * Provided more informative logging in case of unexpected errors (`ASFFreeGamesOptionsLoader.cs`).
* Optimized HttpClient usage:
    * Introduced `SimpleHttpClient` class with improved configuration options (`SimpleHttpClient.cs`).
    * Set default `MaxConnectionsPerServer` to limit resource usage (`SimpleHttpClient.cs`).
    * Implemented a workaround for missing `CheckCertificateRevocationList` property (`SimpleHttpClient.cs`).
    * Improved stream handling in `HttpStreamResponse` class to gracefully handle potential null streams (`SimpleHttpClient.cs`, `HttpStreamResponse.cs`).
* Minor improvements:
    * Added comments and code formatting for better readability.
    * Updated code to adhere to modern C# practices.

These changes ensure compatibility with recent ASF versions, provide better error handling for configuration issues, and optimize the performance and reliability of the plugin's network communication.
  • Loading branch information
maxisoft committed Aug 8, 2024
1 parent 1db9d01 commit d42db40
Show file tree
Hide file tree
Showing 5 changed files with 383 additions and 12 deletions.
58 changes: 58 additions & 0 deletions ASFFreeGames.Tests/Configurations/ASFFreeGamesOptionsSaverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
using ASFFreeGames.Configurations;
using Xunit;

namespace Maxisoft.ASF.Tests.Configurations;

public class ASFFreeGamesOptionsSaverTests {
[Fact]
#pragma warning disable CA1707
public async void SaveOptions_WritesValidJson_And_ParsesCorrectly() {
#pragma warning restore CA1707

// Arrange
ASFFreeGamesOptions options = new() {
RecheckInterval = TimeSpan.FromHours(1),
RandomizeRecheckInterval = true,
SkipFreeToPlay = false,
SkipDLC = true,
Blacklist = new HashSet<string> {
"game1",
"game2",
"a gamewith2xquote(\")'",
"game with strange char €$çêà /\\\n\r\t"
},
VerboseLog = null,
Proxy = "http://localhost:1080",
RedditProxy = "socks5://192.168.1.1:1081"
};

using MemoryStream memoryStream = new();

// Act
_ = await ASFFreeGamesOptionsSaver.SaveOptions(memoryStream, options).ConfigureAwait(false);

// Assert - Validate UTF-8 encoding
memoryStream.Position = 0;
Assert.NotEmpty(Encoding.UTF8.GetString(memoryStream.ToArray()));

// Assert - Parse JSON and access properties
memoryStream.Position = 0;
string json = Encoding.UTF8.GetString(memoryStream.ToArray());
ASFFreeGamesOptions? deserializedOptions = JsonSerializer.Deserialize<ASFFreeGamesOptions>(json);

Assert.NotNull(deserializedOptions);
Assert.Equal(options.RecheckInterval, deserializedOptions.RecheckInterval);
Assert.Equal(options.RandomizeRecheckInterval, deserializedOptions.RandomizeRecheckInterval);
Assert.Equal(options.SkipFreeToPlay, deserializedOptions.SkipFreeToPlay);
Assert.Equal(options.SkipDLC, deserializedOptions.SkipDLC);
Assert.Equal(options.Blacklist, deserializedOptions.Blacklist);
Assert.Equal(options.VerboseLog, deserializedOptions.VerboseLog);
Assert.Equal(options.Proxy, deserializedOptions.Proxy);
Assert.Equal(options.RedditProxy, deserializedOptions.RedditProxy);
}
}
2 changes: 2 additions & 0 deletions ASFFreeGames/Commands/GetIp/GetIPCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ internal sealed class GetIPCommand : IBotCommand {
}
}
catch (Exception e) when (e is JsonException or IOException) {
#pragma warning disable CA1863
return IBotCommand.FormatBotResponse(bot, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, e.Message));
#pragma warning restore CA1863
}

return null;
Expand Down
22 changes: 18 additions & 4 deletions ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
Expand Down Expand Up @@ -58,13 +59,26 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can
#pragma warning disable CA2007

// Use FileOptions.Asynchronous when creating a file stream for async operations
await using FileStream fs = new(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous);
await using FileStream fs = new(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite, 4096, FileOptions.Asynchronous);
#pragma warning restore CA2007
#pragma warning restore CAC001
using IMemoryOwner<byte> buffer = MemoryPool<byte>.Shared.Rent(checked(fs.Length > 0 ? (int) fs.Length + 1 : 1 << 15));
int read = await fs.ReadAsync(buffer.Memory, cancellationToken).ConfigureAwait(false);

// Use JsonSerializerOptions.PropertyNamingPolicy to specify the JSON property naming convention
await JsonSerializer.SerializeAsync(fs, options, cancellationToken: cancellationToken).ConfigureAwait(false);
fs.SetLength(fs.Position);
try {
fs.Position = 0;
fs.SetLength(0);
int written = await ASFFreeGamesOptionsSaver.SaveOptions(fs, options, true, cancellationToken).ConfigureAwait(false);
fs.SetLength(written);
}

catch (Exception) {
fs.Position = 0;
await fs.WriteAsync(buffer.Memory[..read], cancellationToken).ConfigureAwait(false);
fs.SetLength(read);

throw;
}
}
finally {
Semaphore.Release();
Expand Down
220 changes: 220 additions & 0 deletions ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;

#nullable enable
namespace ASFFreeGames.Configurations;

public static class ASFFreeGamesOptionsSaver {
public static async Task<int> SaveOptions([NotNull] Stream stream, [NotNull] ASFFreeGamesOptions options, bool checkValid = true, CancellationToken cancellationToken = default) {
using IMemoryOwner<byte> memory = MemoryPool<byte>.Shared.Rent(1 << 15);
int written = CreateOptionsBuffer(options, memory);

if (checkValid) {
PseudoValidate(memory, written);
}

await stream.WriteAsync(memory.Memory[..written], cancellationToken).ConfigureAwait(false);

return written;
}

private static void PseudoValidate(IMemoryOwner<byte> memory, int written) {
JsonNode? doc = JsonNode.Parse(Encoding.UTF8.GetString(memory.Memory[..written].Span));

doc?["skipFreeToPlay"]?.GetValue<bool?>();
}

internal static int CreateOptionsBuffer(ASFFreeGamesOptions options, IMemoryOwner<byte> memory) {
Span<byte> buffer = memory.Memory.Span;
buffer.Clear();

int written = 0;
written += WriteJsonString("{\n"u8, buffer, written);

written += WriteNameAndProperty("recheckInterval"u8, options.RecheckInterval, buffer, written);
written += WriteNameAndProperty("randomizeRecheckInterval"u8, options.RandomizeRecheckInterval, buffer, written);
written += WriteNameAndProperty("skipFreeToPlay"u8, options.SkipFreeToPlay, buffer, written);
written += WriteNameAndProperty("skipDLC"u8, options.SkipDLC, buffer, written);
written += WriteNameAndProperty("blacklist"u8, options.Blacklist, buffer, written);
written += WriteNameAndProperty("verboseLog"u8, options.VerboseLog, buffer, written);
written += WriteNameAndProperty("proxy"u8, options.Proxy, buffer, written);
written += WriteNameAndProperty("redditProxy"u8, options.RedditProxy, buffer, written);
RemoveTrailingCommaAndLineReturn(buffer, ref written);

written += WriteJsonString("\n}"u8, buffer, written);

// Resize buffer if needed
if (written >= buffer.Length) {
throw new InvalidOperationException("Buffer overflow while saving options");
}

return written;
}

private static void RemoveTrailingCommaAndLineReturn(Span<byte> buffer, ref int written) {
int c;

do {
c = RemoveTrailing(buffer, "\n"u8, ref written);
c += RemoveTrailing(buffer, ","u8, ref written);
} while (c > 0);
}

private static int RemoveTrailing(Span<byte> buffer, ReadOnlySpan<byte> target, ref int written) {
Span<byte> sub = buffer[..written];
int c = 0;

while (!sub.IsEmpty) {
if (sub.EndsWith(target)) {
written -= target.Length;
sub = sub[..written];
c += 1;
}
else {
break;
}
}

return c;
}

[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private static int WriteEscapedJsonString(string str, Span<byte> buffer, int written) {
const byte quote = (byte) '"';
const byte backslash = (byte) '\\';

int startIndex = written;
buffer[written++] = quote;
Span<char> cstr = stackalloc char[1];
ReadOnlySpan<char> span = str.AsSpan();

// ReSharper disable once ForCanBeConvertedToForeach
for (int index = 0; index < span.Length; index++) {
char c = span[index];

switch (c) {
case '"':
buffer[written++] = backslash;
buffer[written++] = quote;

break;
case '\\':
buffer[written++] = backslash;
buffer[written++] = backslash;

break;
case '\b':
buffer[written++] = backslash;
buffer[written++] = (byte) 'b';

break;
case '\f':
buffer[written++] = backslash;
buffer[written++] = (byte) 'f';

break;
case '\n':
buffer[written++] = backslash;
buffer[written++] = (byte) 'n';

break;
case '\r':
buffer[written++] = backslash;
buffer[written++] = (byte) 'r';

break;
case '\t':
buffer[written++] = backslash;
buffer[written++] = (byte) 't';

break;
default:
// Optimize for common case of ASCII characters
if (c < 128) {
buffer[written++] = (byte) c;
}
else {
cstr[0] = c;
written += WriteJsonString(cstr, buffer, written);
}

break;
}
}

buffer[written++] = quote;

return written - startIndex;
}

[MethodImpl(MethodImplOptions.AggressiveOptimization)]
private static int WriteNameAndProperty<T>(ReadOnlySpan<byte> name, T value, Span<byte> buffer, int written) {
int startIndex = written;
written += WriteJsonString("\""u8, buffer, written);
written += WriteJsonString(name, buffer, written);
written += WriteJsonString("\": "u8, buffer, written);

if (value is null) {
written += WriteJsonString("null"u8, buffer, written);
}
else {
written += value switch {
string str => WriteEscapedJsonString(str, buffer, written),
#pragma warning disable CA1308
bool b => WriteJsonString(b ? "true"u8 : "false"u8, buffer, written),
#pragma warning restore CA1308
IReadOnlyCollection<string> collection => WriteJsonArray(collection, buffer, written),
TimeSpan timeSpan => WriteEscapedJsonString(timeSpan.ToString(), buffer, written),
_ => throw new ArgumentException($"Unsupported type for property {Encoding.UTF8.GetString(name)}: {value.GetType()}")
};
}

written += WriteJsonString(","u8, buffer, written);
written += WriteJsonString("\n"u8, buffer, written);

return written - startIndex;
}

private static int WriteJsonArray(IEnumerable<string> collection, Span<byte> buffer, int written) {
int startIndex = written;
written += WriteJsonString("["u8, buffer, written);
bool first = true;

foreach (string item in collection) {
if (!first) {
written += WriteJsonString(","u8, buffer, written);
}

written += WriteEscapedJsonString(item, buffer, written);
first = false;
}

written += WriteJsonString("]"u8, buffer, written);

return written - startIndex;
}

[MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)]
private static int WriteJsonString(ReadOnlySpan<byte> str, Span<byte> buffer, int written) {
str.CopyTo(buffer[written..(written + str.Length)]);

return str.Length;
}

[MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)]
private static int WriteJsonString(ReadOnlySpan<char> str, Span<byte> buffer, int written) {
int encodedLength = Encoding.UTF8.GetBytes(str, buffer[written..]);

return encodedLength;
}
}
Loading

0 comments on commit d42db40

Please sign in to comment.