C
C#5mo ago
13eck

My Discord app is using all my memory. What am I doing wrong?

I'm making what I thought was a simple dice rolling app for Discord. The user tells it how many dice to roll, any static modifiers to the total, and an optional description for the action being taken. However, when I run the app I can only do a small handful of commands before my computer tells me that VS Code is using too much memory and I need to close it down. Sometimes the console even says something about heartbeat being too slow and a possible thread starvation. I don't have that much code written so I'm not sure what's going on, but here's what I currently have:
// Program.cs
using D6.Api.Commands;
using D6.Api.Discord;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using System.Text.Json.Nodes;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<DiscordHelper>();

var app = builder.Build();

app.MapPost("/", async (HttpContext ctx,
[FromHeader(Name = "X-Signature-Ed25519")] string sig,
[FromHeader(Name = "X-Signature-Timestamp")] string timestamp,
[FromServices] DiscordHelper DiscordHelper) =>
{

using StreamReader reader = new(ctx.Request.Body);
string body = await reader.ReadToEndAsync();
JsonDocument jsonBody = JsonDocument.Parse(body);
JsonElement interaction = jsonBody.RootElement.Clone();
jsonBody.Dispose();

bool is_verified = DiscordHelper.VerifySignature(body, timestamp, sig);

if (!is_verified)
{
return Results.Unauthorized();
}

int interaction_type = interaction.GetProperty("type").GetInt32();

if (interaction_type == 1)
{
return Results.Ok(new JsonObject
{
["type"] = 1
});
}

string CommandName = interaction.GetProperty("data").GetProperty("name").GetString()!;

JsonObject JsonReply = CommandName switch
{
"d6" => Commands.D6(interaction),
_ => new JsonObject()
};

return Results.Ok(JsonReply);
});

app.Run();
// Program.cs
using D6.Api.Commands;
using D6.Api.Discord;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using System.Text.Json.Nodes;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<DiscordHelper>();

var app = builder.Build();

app.MapPost("/", async (HttpContext ctx,
[FromHeader(Name = "X-Signature-Ed25519")] string sig,
[FromHeader(Name = "X-Signature-Timestamp")] string timestamp,
[FromServices] DiscordHelper DiscordHelper) =>
{

using StreamReader reader = new(ctx.Request.Body);
string body = await reader.ReadToEndAsync();
JsonDocument jsonBody = JsonDocument.Parse(body);
JsonElement interaction = jsonBody.RootElement.Clone();
jsonBody.Dispose();

bool is_verified = DiscordHelper.VerifySignature(body, timestamp, sig);

if (!is_verified)
{
return Results.Unauthorized();
}

int interaction_type = interaction.GetProperty("type").GetInt32();

if (interaction_type == 1)
{
return Results.Ok(new JsonObject
{
["type"] = 1
});
}

string CommandName = interaction.GetProperty("data").GetProperty("name").GetString()!;

JsonObject JsonReply = CommandName switch
{
"d6" => Commands.D6(interaction),
_ => new JsonObject()
};

return Results.Ok(JsonReply);
});

app.Run();
43 Replies
13eck
13eckOP5mo ago
// DiscordHelper.cs
using System.Text;
using NSec.Cryptography;


namespace D6.Api.Discord;

public class DiscordHelper
{
private readonly PublicKey public_key;
private readonly SignatureAlgorithm algo;

public DiscordHelper()
{
ReadOnlySpan<byte> string_key = Convert.FromHexString("acda45c91f61e68d267d51d44a3fbd8142859875b77a1f7198421b7ab32f654d");
public_key = PublicKey.Import(SignatureAlgorithm.Ed25519, string_key, KeyBlobFormat.RawPublicKey);
algo = SignatureAlgorithm.Ed25519;
}

public bool VerifySignature(string jsonString, string timestamp, string signature)
{

ReadOnlySpan<byte> signatureSpan = Convert.FromHexString(signature);
ReadOnlySpan<byte> data = Encoding.UTF8.GetBytes($"{timestamp}{jsonString}");
bool isVerified = algo.Verify(public_key, data, signatureSpan);
return isVerified;
}

}
// DiscordHelper.cs
using System.Text;
using NSec.Cryptography;


namespace D6.Api.Discord;

public class DiscordHelper
{
private readonly PublicKey public_key;
private readonly SignatureAlgorithm algo;

public DiscordHelper()
{
ReadOnlySpan<byte> string_key = Convert.FromHexString("acda45c91f61e68d267d51d44a3fbd8142859875b77a1f7198421b7ab32f654d");
public_key = PublicKey.Import(SignatureAlgorithm.Ed25519, string_key, KeyBlobFormat.RawPublicKey);
algo = SignatureAlgorithm.Ed25519;
}

public bool VerifySignature(string jsonString, string timestamp, string signature)
{

ReadOnlySpan<byte> signatureSpan = Convert.FromHexString(signature);
ReadOnlySpan<byte> data = Encoding.UTF8.GetBytes($"{timestamp}{jsonString}");
bool isVerified = algo.Verify(public_key, data, signatureSpan);
return isVerified;
}

}
// Commands.cs
using System.Text.Json;
using System.Text.Json.Nodes;
using D6.Api.Dice;

namespace D6.Api.Commands;

public class Commands
{

public static JsonObject D6(JsonElement interaction)
{
// get data from the interaction

int numDiceToRoll = 0;
int numPip = 0;
string description = "";

foreach (JsonElement option in interaction.GetProperty("data").GetProperty("options").EnumerateArray())
{
string name = option.GetProperty("name").GetString()!;
if (name == "description") { description = option.GetProperty("value").GetString()!; }
else if (name == "die_code") { numDiceToRoll = option.GetProperty("value").GetInt32(); }
else if (name == "pips") { numPip = option.GetProperty("value").GetInt32(); }
};

// roll xD6
DiceRoller dice = new();

List<DieCode> normalDice = dice.RollDieCode(numDiceToRoll - 1);
List<DieCode> wildDie = dice.RollWild();
List<DieCode> combined = [.. normalDice, .. wildDie];

int total = numPip;
string emojis = combined.Aggregate("", (a, d) =>
{
total += d.Value;
a += $"{d.Markdown} ";
return a;
});


return new JsonObject()
{
["type"] = 4,
["data"] = new JsonObject
{
["embeds"] = new JsonArray{
new JsonObject{
["title"] = $"You got {total}!",
["description"] = emojis
}
}
}
};
}
}
// Commands.cs
using System.Text.Json;
using System.Text.Json.Nodes;
using D6.Api.Dice;

namespace D6.Api.Commands;

public class Commands
{

public static JsonObject D6(JsonElement interaction)
{
// get data from the interaction

int numDiceToRoll = 0;
int numPip = 0;
string description = "";

foreach (JsonElement option in interaction.GetProperty("data").GetProperty("options").EnumerateArray())
{
string name = option.GetProperty("name").GetString()!;
if (name == "description") { description = option.GetProperty("value").GetString()!; }
else if (name == "die_code") { numDiceToRoll = option.GetProperty("value").GetInt32(); }
else if (name == "pips") { numPip = option.GetProperty("value").GetInt32(); }
};

// roll xD6
DiceRoller dice = new();

List<DieCode> normalDice = dice.RollDieCode(numDiceToRoll - 1);
List<DieCode> wildDie = dice.RollWild();
List<DieCode> combined = [.. normalDice, .. wildDie];

int total = numPip;
string emojis = combined.Aggregate("", (a, d) =>
{
total += d.Value;
a += $"{d.Markdown} ";
return a;
});


return new JsonObject()
{
["type"] = 4,
["data"] = new JsonObject
{
["embeds"] = new JsonArray{
new JsonObject{
["title"] = $"You got {total}!",
["description"] = emojis
}
}
}
};
}
}
// Dice.cs
using System.Text;

namespace D6.Api.Dice;

public class DiceRoller()
{
private readonly Random rand = new();
private readonly DieCode[] normalDice = [new(":dn1:", 1),
new(":dn2:", 2),
new(":dn3:", 3),
new(":dn4:", 4),
new(":dn5:", 5),
new(":dn6:", 6)];
private readonly DieCode[] WildDice = [new(":wn1:", 1),
new(":wn2:", 2),
new(":wn3:", 3),
new(":wn4:", 4),
new(":wn5:", 5),
new(":wn6:", 6)];


public List<DieCode> RollDieCode(int numToRoll)
{
if (numToRoll < 1) { numToRoll = 1; }
IEnumerable<DieCode> storage = Enumerable.Range(0, numToRoll).Select(_ =>
{
int idx = rand.Next(6);
return normalDice[idx];
});

return storage.ToList();
}

public List<DieCode> RollWild()
{

int idx = rand.Next(6);
List<DieCode> storage = new();

do
{
storage.Add(WildDice[idx]);
} while (idx == 5);

return storage;
}

}
public record DieCode(
string Markdown,
int Value
);
// Dice.cs
using System.Text;

namespace D6.Api.Dice;

public class DiceRoller()
{
private readonly Random rand = new();
private readonly DieCode[] normalDice = [new(":dn1:", 1),
new(":dn2:", 2),
new(":dn3:", 3),
new(":dn4:", 4),
new(":dn5:", 5),
new(":dn6:", 6)];
private readonly DieCode[] WildDice = [new(":wn1:", 1),
new(":wn2:", 2),
new(":wn3:", 3),
new(":wn4:", 4),
new(":wn5:", 5),
new(":wn6:", 6)];


public List<DieCode> RollDieCode(int numToRoll)
{
if (numToRoll < 1) { numToRoll = 1; }
IEnumerable<DieCode> storage = Enumerable.Range(0, numToRoll).Select(_ =>
{
int idx = rand.Next(6);
return normalDice[idx];
});

return storage.ToList();
}

public List<DieCode> RollWild()
{

int idx = rand.Next(6);
List<DieCode> storage = new();

do
{
storage.Add(WildDice[idx]);
} while (idx == 5);

return storage;
}

}
public record DieCode(
string Markdown,
int Value
);
becquerel
becquerel5mo ago
nothing stands out as immediately wrong... the warning about potential thread starvation is interesting, since the only thing here I can see which would spin up more threads is just receiving requests to your endpoint. i would try trimming out as much of the code as you can (sticking a 'return' at some early point in your MapPost, for instance) to see what minimal example you can get which reproduces the issue
Keswiik
Keswiik5mo ago
if you're doing this in visual studio, use the memory profiler and observe how memory usage changes as you execute commands
becquerel
becquerel5mo ago
they're in vscode but i would second that recommendation
Keswiik
Keswiik5mo ago
shit, missed that part need more caffeine :Smug:
13eck
13eckOP5mo ago
VS Code on Mac Though now I'm wondering if it's the VS Code terminal. I'll try running it from the actual terminal to see if anything changes Well I was able to run a lot more commands before the out of memory alert popped up…but it still popped up. I guess I'll throw up some early return statements to see what I can find out
becquerel
becquerel5mo ago
what is the specific error message you're getting related to heartbeats and thread starvation, out of curiosity?
13eck
13eckOP5mo ago
As of "08/16/2024 17:16:46 +00:00", the heartbeat has been running for "00:00:01.0572152" which is longer than "00:00:01". This could be caused by thread pool starvation.
becquerel
becquerel5mo ago
ah, ok. searching that up confirmed this error is coming from within aspnet core
becquerel
becquerel5mo ago
Debug ThreadPool Starvation - .NET
A tutorial that walks you through debugging and fixing a ThreadPool starvation issue on .NET Core
becquerel
becquerel5mo ago
and specifically trying out dotnet-counters it should let you hopefully get a better view on when new threads are being spawned how are you testing this, by the way? could it be an issue where you're triggering your endpoint way more frequently than intended, by some misconfiguration with discord?
13eck
13eckOP5mo ago
I added in early return statements at various parts of the code and have found the error to happen somewhere in here:
// In Commands.cs
DiceRoller dice = new();

List<DieCode> normalDice = dice.RollDieCode(numDiceToRoll - 1);
List<DieCode> wildDie = dice.RollWild();
List<DieCode> combined = [.. normalDice, .. wildDie];
// In Commands.cs
DiceRoller dice = new();

List<DieCode> normalDice = dice.RollDieCode(numDiceToRoll - 1);
List<DieCode> wildDie = dice.RollWild();
List<DieCode> combined = [.. normalDice, .. wildDie];
I'm running it on localhost and using NGrok to tunnel to my machine and running the slash command in a test server
becquerel
becquerel5mo ago
🤔 how strange...
13eck
13eckOP5mo ago
So it seems the issue is in the DiceRoller class
becquerel
becquerel5mo ago
the only possible thing I can think of is that you're maybe instantiating the diceroller way more than you expect to, somehow since there's nothing in there doing anything relating to threading what if you remove all the calls to Random.Next() and replace them with hardcoded values?
13eck
13eckOP5mo ago
Would it be better to register it as a singleton or something? So I'm not making a new one for each API call?
Keswiik
Keswiik5mo ago
i'd register it as a singleton, don't see much point in making a new instance of it every time
becquerel
becquerel5mo ago
plausibly, though i would be really surprised if a class that lightweight is causing issues
Keswiik
Keswiik5mo ago
my only guess is that something could maintain a reference to it, so it (or something it uses) never gets GC'd.... but it doesn't look like that should be the case anyways so i'm not sure
becquerel
becquerel5mo ago
i will be fascinated to find out what the root cause of this is
Keswiik
Keswiik5mo ago
memory profiler of any kind would make this super simple to debug :pepeHands:
13eck
13eckOP5mo ago
Is there a memory profiler for VS Code for Mac? Or is that something I need to find on the machine itself?
Keswiik
Keswiik5mo ago
honestly not aware of any good profilers for mac not sure if visual studio community has a mac build either (iirc VS for mac is getting discontinued soon anyways?) but it's something you could check but i would go with bec's advice and see what happens when you get rid of your Random calls you can also make your normalDice and WildDice arrays static, no reason to instantiate those each time
13eck
13eckOP5mo ago
If I do that would I need to instantiate a new Random inside each, then? Or how does class properites work for static methods? I'm still pretty new to C# and .NET so I apologize that I don't know how things work
becquerel
becquerel5mo ago
you can use the Random.Shared property to have only one instance of it
13eck
13eckOP5mo ago
How do I do that?
becquerel
becquerel5mo ago
a static property inside a class means it essentially only exists once, no matter how many times you new() up the class
13eck
13eckOP5mo ago
Oh, I see how!
becquerel
becquerel5mo ago
as in, you can write 'var number = Random.Shared.Next()';
Keswiik
Keswiik5mo ago
Static Classes and Static Class Members - C#
Static classes can't be instantiated in C#. You access the members of a static class by using the class name itself.
Keswiik
Keswiik5mo ago
some relevant docs on statics in c#
13eck
13eckOP5mo ago
Thanks! Well, tried to replace the Random.next() calls with a pre-defined array of indices and I wasn't even able to call the command once before it shat the bed 😭
public static List<DieCode> RollDieCode(int numToRoll)
{
int[] fakeRand = [5,1,3,2,5,3,0,2,3,4,1,5,0,3,1,4,5,0];
if (numToRoll < 1) { numToRoll = 1; }
int i = 0;
IEnumerable<DieCode> storage = Enumerable.Range(0, numToRoll).Select(_ =>
{
int idx = fakeRand[i];
i++;
return normalDice[idx];
});

return storage.ToList();
}
public static List<DieCode> RollWild()
{
int[] fakeRand = [5,1,3,2,5,3,0,2,3,4,1,5,0,3,1,4,5,0];
int i = 0;
int idx = fakeRand[i];
List<DieCode> storage = new();

do
{
storage.Add(WildDice[idx]);
i++;
} while (idx == 5);

return storage;
}
public static List<DieCode> RollDieCode(int numToRoll)
{
int[] fakeRand = [5,1,3,2,5,3,0,2,3,4,1,5,0,3,1,4,5,0];
if (numToRoll < 1) { numToRoll = 1; }
int i = 0;
IEnumerable<DieCode> storage = Enumerable.Range(0, numToRoll).Select(_ =>
{
int idx = fakeRand[i];
i++;
return normalDice[idx];
});

return storage.ToList();
}
public static List<DieCode> RollWild()
{
int[] fakeRand = [5,1,3,2,5,3,0,2,3,4,1,5,0,3,1,4,5,0];
int i = 0;
int idx = fakeRand[i];
List<DieCode> storage = new();

do
{
storage.Add(WildDice[idx]);
i++;
} while (idx == 5);

return storage;
}
Is it because I'm using records instead of classes? I Thought they were supposed to be more efficient for "dumb data containers". It can't be that I'm doing too many lists, right? I'm gonna take a break and come back later with fresh eyes. Maybe something will pop out at me… (I also made the two dice arrays static so the static methods could use them)
becquerel
becquerel5mo ago
there is definitely something extremely strange going on here that is not at all obvious from your code so i highly suspect something weird in your environment or testing mechanism it's a shame you don't have good profiling tools on mac... maybe you can resort to logging Thread.CurrentThread or something lmao to see when new threads are being spawned
13eck
13eckOP5mo ago
So I have no idea WTF I did or didn't do…but it's been fixed?
// Dice.cs
using System.Text;

namespace D6.Api.Dice;

public class DiceRoller
{
private readonly static DieCode[] normalDice = [new(":dn1:", 1),
new(":dn2:", 2),
new(":dn3:", 3),
new(":dn4:", 4),
new(":dn5:", 5),
new(":dn6:", 6)];
private readonly static DieCode[] WildDice = [new(":wn1:", 1),
new(":wn2:", 2),
new(":wn3:", 3),
new(":wn4:", 4),
new(":wn5:", 5),
new(":wn6:", 6)];




public static List<DieCode> RollDieCode(int numToRoll)
{
if (numToRoll < 1) { numToRoll = 1; }

IEnumerable<DieCode> storage = Enumerable.Range(0, numToRoll).Select(_ =>
{
int idx = Random.Shared.Next(6);
return normalDice[idx];
});

return storage.ToList();
}

public static List<DieCode> RollWild()
{
List<DieCode> storage = new();
int idx = 0;
do
{
idx = Random.Shared.Next(6);
storage.Add(WildDice[idx]);
} while (idx == 5);

return storage;
}

}
public record DieCode(
string Markdown,
int Value
);
// Dice.cs
using System.Text;

namespace D6.Api.Dice;

public class DiceRoller
{
private readonly static DieCode[] normalDice = [new(":dn1:", 1),
new(":dn2:", 2),
new(":dn3:", 3),
new(":dn4:", 4),
new(":dn5:", 5),
new(":dn6:", 6)];
private readonly static DieCode[] WildDice = [new(":wn1:", 1),
new(":wn2:", 2),
new(":wn3:", 3),
new(":wn4:", 4),
new(":wn5:", 5),
new(":wn6:", 6)];




public static List<DieCode> RollDieCode(int numToRoll)
{
if (numToRoll < 1) { numToRoll = 1; }

IEnumerable<DieCode> storage = Enumerable.Range(0, numToRoll).Select(_ =>
{
int idx = Random.Shared.Next(6);
return normalDice[idx];
});

return storage.ToList();
}

public static List<DieCode> RollWild()
{
List<DieCode> storage = new();
int idx = 0;
do
{
idx = Random.Shared.Next(6);
storage.Add(WildDice[idx]);
} while (idx == 5);

return storage;
}

}
public record DieCode(
string Markdown,
int Value
);
I ran the command over 30 times in a row and there's a bit of lag, but I assume that's because of NGrok and when it's on a real server it'll be faster The Dice.cs file is the only one I made any changes to
becquerel
becquerel5mo ago
:thimking: what were the changes?
Keswiik
Keswiik5mo ago
using shared random it seems
13eck
13eckOP5mo ago
Shared random, yeah, and for some reason the class name was originally public class DiceRoller() instead of public class DiceRoller. I assume the () did something wonky
becquerel
becquerel5mo ago
i think the () would've just added a 'primary constructor' that did nothing which was likely not relevant interesting that using random.shared had that big an impact it would imply you were creating a truly massive number of DiceRollers
13eck
13eckOP5mo ago
No idea why that is, but at least it's working better now. Next step is to get the rest of the functionality working (asking for description of the action behind the dice roll) and then getting a single DLL or whatever to upload it to my linux server. My other Discord apps were written in Node.js and Go…so this is my first C#/.NET Discord app!
becquerel
becquerel5mo ago
🙂 congrats!
13eck
13eckOP5mo ago
Thanks for the help, (other) bec! lol
becquerel
becquerel5mo ago
lol no worries
13eck
13eckOP5mo ago
If y'all wanna see it in action it's up and running! https://discord.com/oauth2/authorize?client_id=1274372466642260072
Want results from more Discord servers?
Add your server