diff --git a/.skills/csharp-async-best-practices/SKILL.md b/.skills/csharp-async-best-practices/SKILL.md new file mode 100644 index 00000000..fd4f6fa1 --- /dev/null +++ b/.skills/csharp-async-best-practices/SKILL.md @@ -0,0 +1,120 @@ +--- +name: csharp-async-best-practices +description: Use when reviewing, writing, refactoring, or designing c# async code that uses task, task-generic, valuetask, cancellationtoken, task.whenall, task.whenany, task.run, configureawait, async void, or fire-and-forget patterns. Trigger on `.result`, `.wait()`, deadlocks, cancellation propagation, asp.net core background work, ui responsiveness, exception flow, and performance-sensitive async api design. +metadata: + category: technique + triggers: + - c# + - async + - task + - valuetask + - cancellationtoken + - configureawait + - .result + - .wait() + - async void + - fire-and-forget + - task.run + - whenall + - whenany + - asp.net core + - deadlock +--- + +# C# Async Best Practices + +## Overview + +Apply evidence-backed async guidance with this priority order: + +1. correctness and cancellation semantics +2. context-specific API design +3. concurrency behavior and failure handling +4. performance tuning only when the hot path is real + +Treat blanket advice as suspect. Separate official behavior from expert interpretation and from your own recommendation. + +## Workflow + +1. Classify the code before judging it. + - **I/O-bound async**: network, file, database, timers, async waits + - **CPU-bound work**: expensive computation + - **Context**: library, UI app, ASP.NET Core app, background service, test code + - **Pressure**: hot path or ordinary path +2. Prefer the least surprising correct design. +3. Only optimize allocations or scheduling after the correctness story is sound. +4. Load the matching reference file before making strong claims. + - **General rules and code review defaults**: `references/core-guidance.md` + - **Context-sensitive rules**: `references/context-and-tradeoffs.md` + - **Source notes and authority breakdown**: `references/source-notes.md` + +## Review defaults + +Start from these defaults unless the case-specific evidence says otherwise: + +| Topic | Default judgment | +|---|---| +| Blocking on async | usually a defect or interop boundary smell | +| `async void` | only acceptable for event handlers | +| `ValueTask` | avoid by default; justify with measurements or a very hot path | +| `ConfigureAwait(false)` | good library default, not an app-wide default | +| `Task.Run` | use to offload CPU work when needed, not to fake async I/O | +| Fire-and-forget | assume unsafe until lifecycle, scope, and exception handling are explicit | +| `Task.WhenAll` | prefer for independent concurrent operations | +| `Task.WhenAny` | always inspect winner and define what happens to losers | +| Cancellation | accept and propagate token until the point of no cancellation | + +## Output contract + +When you review or design code, label your reasoning like this: + +- **Fact**: official runtime or API behavior +- **Expert guidance**: interpretation from strong experts when it adds design meaning +- **Synthesis**: your recommendation for this exact case + +Do not present contextual advice as a universal law. + +## Common traps + +- Calling `.Result`, `.Wait()`, or `GetAwaiter().GetResult()` inside normal async-capable code +- Recommending `ConfigureAwait(false)` everywhere because “it is .NET Core” or “it prevents deadlocks” +- Recommending `Task.Run` inside ASP.NET Core request code just to make code “more async” +- Recommending `ValueTask` for every hot-looking method without checking completion behavior, call frequency, or single-consumer assumptions +- Ignoring cancellation after plumbing a `CancellationToken` +- Using `Task.WhenAny` without awaiting the returned winner task or handling the remaining tasks +- Treating fire-and-forget as harmless when it touches scoped services, `HttpContext`, or unobserved failures + +## Rationalization traps + +| Rationalization | Better reasoning | +|---|---| +| “It works, so `.Result` is fine.” | Lack of failure under one context does not make blocking safe or scalable. | +| “`ValueTask` is always faster.” | It trades simplicity for niche allocation wins and stricter consumption rules. | +| “`ConfigureAwait(false)` everywhere is modern guidance.” | Library and app code have different constraints. Blanket rules are weak. | +| “`Task.Run` makes server code asynchronous.” | It only queues work; it does not turn blocking I/O into true async I/O. | +| “Fire-and-forget is okay because logging exists.” | Logging does not solve scope lifetime, shutdown, retries, or error propagation. | + +## Deliverable shape + +For code review or implementation help, prefer: + +1. a short context classification +2. the concrete problem +3. the corrected pattern +4. the context-dependent tradeoff, if any +5. the smallest safe code change + +## API shape and testability + +- Prefer `Async` suffixes for awaitable-returning methods unless an established contract or event pattern dictates otherwise. +- Prefer `Task`-returning seams over hidden background work so tests can await completion, faults, and cancellation. +- For timers, queues, retries, or background pipelines, recommend abstractions that let tests control time and observe completion. +- When reviewing an async API, ask whether callers can compose it, cancel it, await it, and assert its failure behavior. + +## Hard boundaries + +- Do not endorse sync-over-async as a normal design choice. +- Do not suggest `async void` except for event handlers. +- Do not suggest `ValueTask` unless the constraints are understood. +- Do not claim `ConfigureAwait(false)` is always needed or always unnecessary. +- Do not approve fire-and-forget unless ownership, exception handling, and lifetime are explicit. diff --git a/.skills/csharp-async-best-practices/references/context-and-tradeoffs.md b/.skills/csharp-async-best-practices/references/context-and-tradeoffs.md new file mode 100644 index 00000000..f6bdaedb --- /dev/null +++ b/.skills/csharp-async-best-practices/references/context-and-tradeoffs.md @@ -0,0 +1,82 @@ +--- +description: >- + Context-specific async guidance for library code, ui apps, asp.net core, + background work, task.run, configureawait, and performance-sensitive design. +metadata: + tags: [configureawait, task.run, asp.net core, ui, library, performance] + source: mixed +--- + +# Context and Tradeoffs + +## Library code versus app code + +### General-purpose library code +- Prefer APIs that expose true async for I/O-bound work. +- Do not add async wrappers around purely compute-bound methods just to look modern. Expose sync compute APIs and let callers decide whether to offload. +- `ConfigureAwait(false)` is a strong default when the library does not need the caller’s context. +- Avoid ambient assumptions about a UI thread, request context, or test framework behavior. + +### App code +- Prefer the style that fits the app model. +- UI code often needs the original context after `await`. +- ASP.NET Core request code normally does not need `Task.Run` just to stay responsive, because it already runs on thread pool threads. +- Do not present “ASP.NET Core has no synchronization context” as proof that every `ConfigureAwait(false)` discussion is obsolete. + +## `Task.Run` boundaries + +### Good uses +- Offload CPU-bound work so a UI thread can stay responsive. +- Offload CPU work from a caller when that scheduling boundary is deliberate. + +### Weak uses +- Wrapping synchronous I/O to pretend it is true async I/O. +- Calling `Task.Run` and immediately awaiting it in ASP.NET Core request handling when no CPU offload goal exists. +- Using `Task.Run` to hide blocking APIs instead of fixing the underlying API choice. + +## Fire-and-forget + +### Assume unsafe until proven otherwise +A background task needs answers for all of these: +- Who owns its lifetime? +- How are exceptions observed? +- How does shutdown cancel it? +- Does it touch scoped services or request-bound objects? +- Does work need retries, backpressure, or queueing? + +### Safer alternatives +- Await the task normally. +- Queue work to an owned background component. +- In ASP.NET Core, prefer hosted services or a dedicated background queue pattern for long-lived work. +- If scoped services are required in background processing, create an explicit scope instead of capturing request scope objects. + +## `ConfigureAwait` + +### Strong recommendation +- In general-purpose libraries, use `ConfigureAwait(false)` unless the continuation must run in the captured context. + +### Weak recommendation +- “Always use it in app code.” +- “Never use it on .NET Core.” +- “Use it once at the first await and you are done.” + +### Review note +If code after the `await` needs a specific context, say so explicitly. If it does not, the recommendation depends on whether the code is app-level or general-purpose library code. + +## Performance guidance + +### Correctness first +Do not trade API clarity for speculative micro-optimizations. + +### `ValueTask` is performance-specialized +Recommend it only when most of these are true: +1. the method is called very frequently +2. it often completes synchronously or from a reusable source +3. allocation reduction matters on measurements +4. consumers can respect single-consumer semantics +5. task combinator ergonomics are not central to the API + +### Throttling and concurrency control +- `Task.WhenAll` expresses concurrency; it does not limit it. +- For bounded concurrency, use an async gate such as `SemaphoreSlim.WaitAsync`, or platform helpers such as `Parallel.ForEachAsync` when the workload fits. +- Always define what happens to remaining work after the first completion or first failure. diff --git a/.skills/csharp-async-best-practices/references/core-guidance.md b/.skills/csharp-async-best-practices/references/core-guidance.md new file mode 100644 index 00000000..114ad50d --- /dev/null +++ b/.skills/csharp-async-best-practices/references/core-guidance.md @@ -0,0 +1,105 @@ +--- +description: >- + Source-backed core guidance for task, valuetask, cancellation, exception flow, + blocking, and concurrency in c# async code reviews and implementations. +metadata: + tags: [csharp, async, task, valuetask, cancellation, exceptions, concurrency] + source: mixed +--- + +# Core Guidance + +## Facts from official .NET documentation + +### 1. Return types and `async void` +- Async methods should normally return `Task` or `Task`. +- `async void` is intended for event handlers; callers cannot await it and exception handling differs. +- TAP methods that return awaitable types conventionally use the `Async` suffix. + +### 2. Blocking on async +- `Task.Result` is blocking. Prefer `await` in most cases. +- Blocking can deadlock in context-bound environments and reduces scalability even when it does not deadlock. +- `await` on a faulted task rethrows one exception directly; `.Wait()` and `.Result` wrap failures in `AggregateException`. + +### 3. `Task` versus `ValueTask` +- Default to `Task` or `Task` unless there is a demonstrated reason not to. +- `ValueTask` has stricter usage rules. A given instance should generally be awaited only once. +- Do not await the same `ValueTask` multiple times, call `AsTask()` multiple times, or mix consumption techniques on the same instance. +- For synchronously successful `Task`-returning methods, `Task.CompletedTask` is the normal zero-result completion value. + +### 4. Cancellation +- If a TAP method supports cancellation, expose a `CancellationToken`. +- Pass the token to nested operations that should participate in cancellation. +- If an async method throws `OperationCanceledException` associated with the method’s token, the returned task transitions to `Canceled`. +- After a method has completed its work successfully, do not report cancellation instead of success. + +### 5. Exception flow and task combinators +- `Task.WhenAll` does not block the calling thread. +- If any supplied task faults, the `WhenAll` task faults and aggregates the unwrapped exceptions from the component tasks. +- If none fault and at least one is canceled, the `WhenAll` task is canceled. +- `Task.WhenAny` returns a task that completes successfully with the first completed task as its result, even when that winning task itself is faulted or canceled. +- After `WhenAny`, await the returned winner task to propagate its outcome. +- The remaining tasks continue unless you cancel or otherwise handle them. + +## Expert guidance that is strong and technically grounded + +### Stephen Toub +- Use `ConfigureAwait(false)` as the general default for general-purpose library code, because library code should not depend on an app model’s context. +- App-level code is different. UI code often needs the captured context. ASP.NET Core also changes the deadlock discussion because it does not install the classic ASP.NET style synchronization context, but that does not make blanket `ConfigureAwait` advice strong. +- `ValueTask` exists mainly to avoid allocations on frequently synchronous success paths. It is not a general replacement for `Task` because `Task` is more flexible for multiple awaits, caching, and combinators. + +### Andrew Arnott +- Propagate the token until the point of no cancellation. +- Validate arguments before cancellation checks when argument validation should always run. +- Prefer catching `OperationCanceledException` rather than `TaskCanceledException` in general-purpose logic. +- Keep `CancellationToken` last in the parameter list; make it optional mainly on public APIs, not necessarily on internal methods. + +### Stephen Cleary +- “Async all the way” is a strong design guideline, not an absolute law of physics. Sync bridges exist, but they are specialized boundary decisions, not a normal code review recommendation. +- `async void` and sync-over-async both create real observability and composition problems even when a sample appears to work. + +## Naming and testability + +### Naming +- TAP methods that return awaitable types conventionally use the `Async` suffix. Do not force renames when an interface, base class, or event pattern already dictates the name. + +### Testability +- Favor awaitable APIs over hidden work so tests can await completion, assert faults, and drive cancellation deterministically. +- Prefer explicit background components, injected clocks, and owned queues over ad hoc fire-and-forget logic that tests cannot observe. + +## Synthesis for agents + +### Code review defaults +- Treat `.Result`, `.Wait()`, and `GetAwaiter().GetResult()` as likely defects unless the code is a deliberate sync boundary and the caller explicitly cannot be async. +- Prefer `Task`/`Task` for API design. Require an explicit reason before recommending `ValueTask`. +- Require cancellation behavior to be coherent: accepted, propagated, and not silently dropped. +- Prefer `await Task.WhenAll(...)` for independent operations started before awaiting. +- Treat `Task.WhenAny(...)` as incomplete until the winner is awaited and losers are canceled, observed, or intentionally left running. + +### Minimal examples + +#### Avoid sync-over-async +```csharp +// bad +var user = client.GetUserAsync(id).Result; + +// better +var user = await client.GetUserAsync(id); +``` + +#### Use `Task.WhenAll` for parallel I/O +```csharp +var userTask = repo.GetUserAsync(id, ct); +var ordersTask = repo.GetOrdersAsync(id, ct); +await Task.WhenAll(userTask, ordersTask); +return new Dashboard(await userTask, await ordersTask); +``` + +#### Be conservative with `ValueTask` +```csharp +// default +Task GetAsync(string key, CancellationToken ct); + +// specialized hot path only when justified +ValueTask TryGetCachedAsync(string key); +``` diff --git a/.skills/csharp-async-best-practices/references/source-notes.md b/.skills/csharp-async-best-practices/references/source-notes.md new file mode 100644 index 00000000..0d34589d --- /dev/null +++ b/.skills/csharp-async-best-practices/references/source-notes.md @@ -0,0 +1,59 @@ +--- +description: >- + Authority notes and citations for the c# async best practices skill, separating + official documentation, expert interpretation, and synthesized guidance. +metadata: + tags: [sources, citations, authority, notes] + source: external +--- + +# Source Notes + +## Official facts + +- Microsoft Learn, "Implementing the Task-based Asynchronous Pattern" + - https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/implementing-the-task-based-asynchronous-pattern + - Return types, cancellation behavior, `Task.Run` boundaries, and TAP implementation guidance. +- Microsoft Learn, "Consuming the Task-based Asynchronous Pattern" + - https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/consuming-the-task-based-asynchronous-pattern + - `await`, `WhenAll`, `WhenAny`, cancellation propagation, and exception behavior. +- Microsoft Learn, "Async return types" + - https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-return-types + - `Task`, `Task`, `async void`, generalized async return types. +- Microsoft Learn, `ValueTask` API reference + - https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask + - single-consumer warnings and default-to-`Task` guidance. +- Microsoft Learn, ASP.NET Core best practices + - https://learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices + - avoid blocking calls, avoid unnecessary `Task.Run`, background-work cautions. +- Microsoft Learn, hosted services in ASP.NET Core + - https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services + - safe long-lived background work and cancellation during shutdown. + +## Expert guidance used only when technically grounded + +- Stephen Toub, ".NET Blog: ConfigureAwait FAQ" + - https://devblogs.microsoft.com/dotnet/configureawait-faq/ + - best source for context capture semantics and library-vs-app guidance. +- Stephen Toub, ".NET Blog: Understanding the Whys, Whats, and Whens of ValueTask" + - https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/ + - performance rationale and tradeoffs behind `ValueTask`. +- Stephen Toub, ".NET Blog: Await, and UI, and deadlocks! Oh my!" + - https://devblogs.microsoft.com/dotnet/await-and-ui-and-deadlocks-oh-my/ + - canonical deadlock explanation for context-bound code. +- Stephen Toub, ".NET Blog: Task Exception Handling in .NET 4.5" + - https://devblogs.microsoft.com/dotnet/task-exception-handling-in-net-4-5/ + - explains `await` versus blocking exception shape and why `WhenAll` matters. +- Andrew Arnott, "Recommended patterns for CancellationToken" + - https://devblogs.microsoft.com/premier-developer/recommended-patterns-for-cancellationtoken/ + - practical cancellation design heuristics; useful, but not treated as a language/runtime spec. +- Stephen Cleary, "Async/Await - Best Practices in Asynchronous Programming" + - https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming + - useful design interpretation, but older and treated as contextual guidance rather than current official policy. + +## Where the skill is intentionally cautious + +- `ConfigureAwait`: strong guidance exists for libraries, weaker guidance for app code. Blanket rules are rejected. +- `Task.Run`: valid for deliberate CPU offload, weak as a server-side patch for blocking I/O. +- `ValueTask`: supported and useful, but easy to misuse. The skill defaults to `Task` unless evidence is present. +- Fire-and-forget: acceptable only with explicit ownership and lifecycle design, especially in server code. diff --git a/MinecraftClient/ConsoleIO.cs b/MinecraftClient/ConsoleIO.cs index 8a77aaee..908ec890 100644 --- a/MinecraftClient/ConsoleIO.cs +++ b/MinecraftClient/ConsoleIO.cs @@ -233,79 +233,13 @@ namespace MinecraftClient DoClearSuggestions(); return; } + _cancellationTokenSource?.Cancel(); - using var cts = new CancellationTokenSource(); + var cts = new CancellationTokenSource(); _cancellationTokenSource = cts; - var previousTask = _latestTask; - var newTask = new Task(async () => - { - string command = fullCommand[offset..]; - if (command.Length == 0) - { - List sugList = new(); - - sugList.Add(new("/")); - - var childs = McClient.dispatcher.GetRoot().Children; - if (childs is not null) - foreach (var child in childs) - sugList.Add(new(child.Name)); - - foreach (var cmd in Commands) - sugList.Add(new(cmd)); - - SendSuggestions(sugList.ToArray(), new(offset, offset)); - } - else if (command.Length > 0 && command[0] == '/' && !command.Contains(' ')) - { - var sorted = Process.ExtractSorted(command[1..], Commands); - var sugList = new ConsoleInteractive.ConsoleSuggestion.Suggestion[sorted.Count()]; - - int index = 0; - foreach (var sug in sorted) - sugList[index++] = new(sug.Value); - SendSuggestions(sugList, new(offset, offset + command.Length)); - } - else - { - CommandDispatcher? dispatcher = McClient.dispatcher; - if (dispatcher is null) - return; - - ParseResults parse = dispatcher.Parse(command, CmdResult.Empty); - - Brigadier.NET.Suggestion.Suggestions suggestions = await dispatcher.GetCompletionSuggestions(parse, buffer.CursorPosition - offset); - - int sugLen = suggestions.List.Count; - if (sugLen == 0) - { - DoClearSuggestions(); - return; - } - - Dictionary dictionary = new(); - foreach (var sug in suggestions.List) - dictionary.Add(sug.Text, sug.Tooltip?.String); - - var sugList = new ConsoleInteractive.ConsoleSuggestion.Suggestion[sugLen]; - if (cts.IsCancellationRequested) - return; - - Tuple range = new(suggestions.Range.Start + offset, suggestions.Range.End + offset); - var sorted = Process.ExtractSorted(fullCommand[range.Item1..range.Item2], dictionary.Keys); - if (cts.IsCancellationRequested) - return; - - int index = 0; - foreach (var sug in sorted) - sugList[index++] = new(sug.Value, dictionary[sug.Value] ?? string.Empty); - - SendSuggestions(sugList, range); - } - }, cts.Token); + Task newTask = UpdateSuggestionsAsync(fullCommand, offset, buffer.CursorPosition, cts.Token); _latestTask = newTask; - try { newTask.Start(); } catch { } - if (_cancellationTokenSource == cts) _cancellationTokenSource = null; + _ = ObserveAutocompleteTaskAsync(newTask, cts); } else { @@ -314,6 +248,108 @@ namespace MinecraftClient } } + private static async Task UpdateSuggestionsAsync(string fullCommand, int offset, int cursorPosition, CancellationToken cancellationToken) + { + string command = fullCommand[offset..]; + if (command.Length == 0) + { + List suggestionList = new() + { + new("/") + }; + + var childs = McClient.dispatcher.GetRoot().Children; + if (childs is not null) + { + foreach (var child in childs) + suggestionList.Add(new(child.Name)); + } + + foreach (var cmd in Commands) + suggestionList.Add(new(cmd)); + + if (cancellationToken.IsCancellationRequested) + return; + + SendSuggestions(suggestionList.ToArray(), new(offset, offset)); + return; + } + + if (command[0] == '/' && !command.Contains(' ')) + { + var sorted = Process.ExtractSorted(command[1..], Commands); + var suggestionList = new ConsoleInteractive.ConsoleSuggestion.Suggestion[sorted.Count()]; + + int index = 0; + foreach (var suggestion in sorted) + suggestionList[index++] = new(suggestion.Value); + + if (cancellationToken.IsCancellationRequested) + return; + + SendSuggestions(suggestionList, new(offset, offset + command.Length)); + return; + } + + CommandDispatcher? dispatcher = McClient.dispatcher; + if (dispatcher is null) + return; + + ParseResults parse = dispatcher.Parse(command, CmdResult.Empty); + Brigadier.NET.Suggestion.Suggestions suggestions = + await dispatcher.GetCompletionSuggestions(parse, cursorPosition - offset); + + if (cancellationToken.IsCancellationRequested) + return; + + int suggestionCount = suggestions.List.Count; + if (suggestionCount == 0) + { + DoClearSuggestions(); + return; + } + + Dictionary tooltips = new(); + foreach (var suggestion in suggestions.List) + tooltips.Add(suggestion.Text, suggestion.Tooltip?.String); + + Tuple range = new(suggestions.Range.Start + offset, suggestions.Range.End + offset); + var sortedSuggestions = Process.ExtractSorted(fullCommand[range.Item1..range.Item2], tooltips.Keys); + if (cancellationToken.IsCancellationRequested) + return; + + var suggestionListWithTooltips = new ConsoleInteractive.ConsoleSuggestion.Suggestion[suggestionCount]; + int suggestionIndex = 0; + foreach (var suggestion in sortedSuggestions) + suggestionListWithTooltips[suggestionIndex++] = new(suggestion.Value, tooltips[suggestion.Value] ?? string.Empty); + + SendSuggestions(suggestionListWithTooltips, range); + } + + private static async Task ObserveAutocompleteTaskAsync(Task task, CancellationTokenSource cancellationTokenSource) + { + try + { + await task; + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + } + catch (Exception e) + { + if (Settings.Config.Logging.DebugMessages) + WriteLogLine(e.ToString(), acceptnewlines: true); + DoClearSuggestions(); + } + finally + { + if (ReferenceEquals(_cancellationTokenSource, cancellationTokenSource)) + _cancellationTokenSource = null; + + cancellationTokenSource.Dispose(); + } + } + public static void AutocompleteHandler(object? sender, ConsoleInputBuffer buffer) { if (Settings.Config.Console.CommandSuggestion.Enable) diff --git a/MinecraftClient/Crypto/AesCfb8Stream.cs b/MinecraftClient/Crypto/AesCfb8Stream.cs index b60eb845..6a7b2769 100644 --- a/MinecraftClient/Crypto/AesCfb8Stream.cs +++ b/MinecraftClient/Crypto/AesCfb8Stream.cs @@ -2,6 +2,8 @@ using System.IO; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; namespace MinecraftClient.Crypto { @@ -59,6 +61,11 @@ namespace MinecraftClient.Crypto BaseStream.Flush(); } + public override Task FlushAsync(CancellationToken cancellationToken) + { + return BaseStream.FlushAsync(cancellationToken); + } + public override long Length { get { throw new NotSupportedException(); } @@ -101,6 +108,15 @@ namespace MinecraftClient.Crypto return (byte)(blockOutput[0] ^ inputBuf); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EncryptBlock(ReadOnlySpan blockInput, Span blockOutput) + { + if (FastAes is not null) + FastAes.EncryptEcb(blockInput, blockOutput); + else + Aes!.EncryptEcb(blockInput, blockOutput, PaddingMode.None); + } + [MethodImpl(MethodImplOptions.AggressiveOptimization)] public override int Read(byte[] buffer, int outOffset, int required) { @@ -122,23 +138,11 @@ namespace MinecraftClient.Crypto } int processEnd = readed + curRead; - if (FastAes is not null) + for (int idx = readed; idx < processEnd; idx++) { - for (int idx = readed; idx < processEnd; idx++) - { - ReadOnlySpan blockInput = new(inputBuf, idx, blockSize); - FastAes.EncryptEcb(blockInput, blockOutput); - buffer[outOffset + idx] = (byte)(blockOutput[0] ^ inputBuf[idx + blockSize]); - } - } - else - { - for (int idx = readed; idx < processEnd; idx++) - { - ReadOnlySpan blockInput = new(inputBuf, idx, blockSize); - Aes!.EncryptEcb(blockInput, blockOutput, PaddingMode.None); - buffer[outOffset + idx] = (byte)(blockOutput[0] ^ inputBuf[idx + blockSize]); - } + ReadOnlySpan blockInput = new(inputBuf, idx, blockSize); + EncryptBlock(blockInput, blockOutput); + buffer[outOffset + idx] = (byte)(blockOutput[0] ^ inputBuf[idx + blockSize]); } } @@ -161,10 +165,7 @@ namespace MinecraftClient.Crypto { Span blockOutput = stackalloc byte[blockSize]; - if (FastAes is not null) - FastAes.EncryptEcb(WriteStreamIV, blockOutput); - else - Aes!.EncryptEcb(WriteStreamIV, blockOutput, PaddingMode.None); + EncryptBlock(WriteStreamIV, blockOutput); byte outputBuf = (byte)(blockOutput[0] ^ b); @@ -185,15 +186,88 @@ namespace MinecraftClient.Crypto for (int wirtten = 0; wirtten < required; ++wirtten) { ReadOnlySpan blockInput = new(outputBuf, wirtten, blockSize); - if (FastAes is not null) - FastAes.EncryptEcb(blockInput, blockOutput); - else - Aes!.EncryptEcb(blockInput, blockOutput, PaddingMode.None); + EncryptBlock(blockInput, blockOutput); outputBuf[blockSize + wirtten] = (byte)(blockOutput[0] ^ input[offset + wirtten]); } - BaseStream.WriteAsync(outputBuf, blockSize, required); + BaseStream.Write(outputBuf, blockSize, required); Array.Copy(outputBuf, required, WriteStreamIV, 0, blockSize); } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (inStreamEnded || buffer.Length == 0) + return 0; + + byte[] inputBuf = new byte[blockSize + buffer.Length]; + Array.Copy(ReadStreamIV, inputBuf, blockSize); + + for (int readed = 0; readed < buffer.Length;) + { + int curRead = await BaseStream.ReadAsync(inputBuf.AsMemory(blockSize + readed, buffer.Length - readed), cancellationToken); + if (curRead == 0) + { + inStreamEnded = true; + Array.Copy(inputBuf, readed, ReadStreamIV, 0, blockSize); + return readed; + } + + int processEnd = readed + curRead; + DecryptToOutputBuffer(inputBuf, buffer, readed, processEnd); + readed = processEnd; + } + + Array.Copy(inputBuf, buffer.Length, ReadStreamIV, 0, blockSize); + return buffer.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (buffer.Length == 0) + return; + + byte[] outputBuf = new byte[blockSize + buffer.Length]; + Array.Copy(WriteStreamIV, outputBuf, blockSize); + EncryptToOutputBuffer(buffer, outputBuf); + + await BaseStream.WriteAsync(outputBuf.AsMemory(blockSize, buffer.Length), cancellationToken); + Array.Copy(outputBuf, buffer.Length, WriteStreamIV, 0, blockSize); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private void DecryptToOutputBuffer(byte[] inputBuf, Memory output, int start, int end) + { + Span blockOutput = stackalloc byte[blockSize]; + for (int idx = start; idx < end; idx++) + { + ReadOnlySpan blockInput = new(inputBuf, idx, blockSize); + EncryptBlock(blockInput, blockOutput); + output.Span[idx] = (byte)(blockOutput[0] ^ inputBuf[idx + blockSize]); + } + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private void EncryptToOutputBuffer(ReadOnlyMemory input, byte[] outputBuf) + { + Span blockOutput = stackalloc byte[blockSize]; + for (int written = 0; written < input.Length; ++written) + { + ReadOnlySpan blockInput = new(outputBuf, written, blockSize); + EncryptBlock(blockInput, blockOutput); + outputBuf[blockSize + written] = (byte)(blockOutput[0] ^ input.Span[written]); + } + } } } diff --git a/MinecraftClient/MainThreadExecutionScope.cs b/MinecraftClient/MainThreadExecutionScope.cs new file mode 100644 index 00000000..226e41eb --- /dev/null +++ b/MinecraftClient/MainThreadExecutionScope.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; + +namespace MinecraftClient +{ + internal static class MainThreadExecutionScope + { + private sealed class ScopeNode(object owner, ScopeNode? parent) : IDisposable + { + public object Owner { get; } = owner; + public ScopeNode? Parent { get; } = parent; + + public void Dispose() + { + if (!ReferenceEquals(s_currentScope.Value, this)) + throw new InvalidOperationException("Main-thread execution scope disposed out of order."); + + s_currentScope.Value = Parent; + } + } + + private static readonly AsyncLocal s_currentScope = new(); + + public static IDisposable Enter(object owner) + { + ScopeNode scopeNode = new(owner, s_currentScope.Value); + s_currentScope.Value = scopeNode; + return scopeNode; + } + + public static bool IsActive(object owner) + { + ScopeNode? scopeNode = s_currentScope.Value; + while (scopeNode is not null) + { + if (ReferenceEquals(scopeNode.Owner, owner)) + return true; + + scopeNode = scopeNode.Parent; + } + + return false; + } + } +} diff --git a/MinecraftClient/Mapping/Movement.cs b/MinecraftClient/Mapping/Movement.cs index 0e972e09..2bc3807c 100644 --- a/MinecraftClient/Mapping/Movement.cs +++ b/MinecraftClient/Mapping/Movement.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace MinecraftClient.Mapping { @@ -150,17 +149,8 @@ namespace MinecraftClient.Mapping public static Queue? CalculatePath(World world, Location start, Location goal, bool allowUnsafe, int maxOffset, int minOffset, TimeSpan timeout) { - CancellationTokenSource cts = new(); - Task?> pathfindingTask = Task.Factory.StartNew(() => - CalculatePath(world, start, goal, allowUnsafe, maxOffset, minOffset, cts.Token)); - pathfindingTask.Wait(timeout); - if (!pathfindingTask.IsCompleted) - { - cts.Cancel(); - pathfindingTask.Wait(); - } - - return pathfindingTask.Result; + using CancellationTokenSource cts = new(timeout); + return CalculatePath(world, start, goal, allowUnsafe, maxOffset, minOffset, cts.Token); } /// @@ -713,4 +703,4 @@ namespace MinecraftClient.Mapping return true; } } -} \ No newline at end of file +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 4d23bb60..50b3f627 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Net.Sockets; using System.Text; using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; using Brigadier.NET; using Brigadier.NET.Exceptions; using MinecraftClient.ChatBots; @@ -42,10 +44,12 @@ namespace MinecraftClient private readonly Queue chatQueue = new(); private static DateTime nextMessageSendTime = DateTime.MinValue; - private readonly Queue threadTasks = new(); + private Queue threadTasks = new(); private readonly Lock threadTasksLock = new(); private readonly Lock recipeBookLock = new(); private readonly Lock achievementsLock = new(); + private readonly Lock consoleCommandProcessingLock = new(); + private readonly Lock networkAutoCompleteLock = new(); private readonly List bots = new(); private static readonly List botsOnHold = new(); @@ -223,7 +227,11 @@ namespace MinecraftClient IMinecraftCom handler = null!; SessionToken _sessionToken; CancellationTokenSource? cmdprompt = null; - Tuple? timeoutdetector = null; + private Channel? consoleCommandChannel; + private Task? consoleCommandProcessingTask; + private TaskCompletionSource? pendingNetworkAutoCompleteRequest; + private TaskCompletionSource? pendingCommandListInitialization; + Tuple? timeoutdetector = null; private int transferInProgress = 0; public ILogger Log; @@ -310,9 +318,10 @@ namespace MinecraftClient handler = Protocol.ProtocolHandler.GetProtocolHandler(client, protocolversion, forgeInfo, this); Log.Info(Translations.mcc_version_supported); - timeoutdetector = new(new Thread(new ParameterizedThreadStart(TimeoutDetector)), new CancellationTokenSource()); - timeoutdetector.Item1.Name = "MCC Connection timeout detector"; - timeoutdetector.Item1.Start(timeoutdetector.Item2.Token); + CancellationTokenSource timeoutDetectorCancellationTokenSource = new(); + Task timeoutDetectorTask = TimeoutDetectorAsync(timeoutDetectorCancellationTokenSource.Token); + timeoutdetector = new(timeoutDetectorTask, timeoutDetectorCancellationTokenSource); + _ = ObserveTimeoutDetectorAsync(timeoutDetectorTask, timeoutDetectorCancellationTokenSource.Token); try { @@ -324,10 +333,7 @@ namespace MinecraftClient Log.Info(string.Format(Translations.mcc_joined, Config.Main.Advanced.InternalCmdChar.ToLogString())); - cmdprompt = new CancellationTokenSource(); - ConsoleIO.Backend.BeginReadThread(); - ConsoleIO.Backend.MessageReceived += ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange += ConsoleIO.AutocompleteHandler; + StartConsoleHandlers(); } else { @@ -369,9 +375,7 @@ namespace MinecraftClient } else if (InternalConfig.InteractiveMode) { - ConsoleIO.Backend.StopReadThread(); - ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + StopConsoleHandlers(); Program.HandleFailure(); } @@ -389,9 +393,7 @@ namespace MinecraftClient // kick messages and Ignore_Kick_Message is false, or retry limit reached) if (InternalConfig.InteractiveMode) { - ConsoleIO.Backend.StopReadThread(); - ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + StopConsoleHandlers(); Program.HandleFailure(); } @@ -415,6 +417,7 @@ namespace MinecraftClient try { Log.Info($"Initiating a transfer to: {newHost}:{newPort}"); + StopConsoleHandlers(); // Unload bots UnloadAllBots(); @@ -449,10 +452,7 @@ namespace MinecraftClient UpdateKeepAlive(); Log.Info($"Successfully transferred connection and logged in to {newHost}:{newPort}."); - cmdprompt = new CancellationTokenSource(); - ConsoleIO.Backend.BeginReadThread(); - ConsoleIO.Backend.MessageReceived += ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange += ConsoleIO.AutocompleteHandler; + StartConsoleHandlers(); } else { @@ -496,9 +496,7 @@ namespace MinecraftClient } else if (InternalConfig.InteractiveMode) { - ConsoleIO.Backend.StopReadThread(); - ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + StopConsoleHandlers(); Program.HandleFailure(); } @@ -703,15 +701,22 @@ namespace MinecraftClient } } + Queue? pendingThreadTasks = null; lock (threadTasksLock) { - while (threadTasks.Count > 0) + if (threadTasks.Count > 0) { - Action taskToRun = threadTasks.Dequeue(); - taskToRun(); + pendingThreadTasks = threadTasks; + threadTasks = new(); } } + if (pendingThreadTasks is not null) + { + while (pendingThreadTasks.Count > 0) + pendingThreadTasks.Dequeue().ExecuteSynchronously(); + } + lock (DigLock) { if (RemainingDiggingTime > 0) @@ -734,29 +739,44 @@ namespace MinecraftClient /// /// Periodically checks for server keepalives and consider that connection has been lost if the last received keepalive is too old. /// - private void TimeoutDetector(object? o) + private async Task TimeoutDetectorAsync(CancellationToken cancellationToken) { UpdateKeepAlive(); - do + using PeriodicTimer periodicTimer = new(TimeSpan.FromSeconds(15)); + try { - Thread.Sleep(TimeSpan.FromSeconds(15)); - - if (((CancellationToken)o!).IsCancellationRequested) - return; - - lock (lastKeepAliveLock) + while (await periodicTimer.WaitForNextTickAsync(cancellationToken)) { - if (lastKeepAlive.AddSeconds(Config.Main.Advanced.TcpTimeout) < DateTime.Now) + lock (lastKeepAliveLock) { - if (((CancellationToken)o!).IsCancellationRequested) - return; + if (lastKeepAlive.AddSeconds(Config.Main.Advanced.TcpTimeout) < DateTime.Now) + { + cancellationToken.ThrowIfCancellationRequested(); - OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.error_timeout); - return; + OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.error_timeout); + return; + } } } } - while (!((CancellationToken)o!).IsCancellationRequested); + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task ObserveTimeoutDetectorAsync(Task timeoutDetectorTask, CancellationToken cancellationToken) + { + try + { + await timeoutDetectorTask; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception e) + { + Log.Warn(e.ToString()); + } } /// @@ -770,6 +790,259 @@ namespace MinecraftClient } } + private void StartConsoleHandlers() + { + if (ConsoleIO.Backend is null) + return; + + cmdprompt = new CancellationTokenSource(); + StartConsoleCommandProcessing(cmdprompt.Token); + ConsoleIO.Backend.BeginReadThread(); + ConsoleIO.Backend.MessageReceived += ConsoleReaderOnMessageReceived; + ConsoleIO.Backend.OnInputChange += ConsoleIO.AutocompleteHandler; + } + + private void StopConsoleHandlers() + { + if (ConsoleIO.Backend is not null) + { + ConsoleIO.Backend.StopReadThread(); + ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; + ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + } + + StopConsoleCommandProcessing(); + } + + private void StartConsoleCommandProcessing(CancellationToken cancellationToken) + { + lock (consoleCommandProcessingLock) + { + consoleCommandChannel = Channel.CreateUnbounded(new UnboundedChannelOptions() + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + consoleCommandProcessingTask = ProcessConsoleMessagesAsync(consoleCommandChannel.Reader, cancellationToken); + _ = ObserveConsoleCommandProcessingAsync(consoleCommandProcessingTask, cancellationToken); + } + } + + private void StopConsoleCommandProcessing() + { + Channel? activeChannel; + + lock (consoleCommandProcessingLock) + { + activeChannel = consoleCommandChannel; + consoleCommandChannel = null; + } + + activeChannel?.Writer.TryComplete(); + + if (cmdprompt is not null) + { + cmdprompt.Cancel(); + cmdprompt = null; + } + + CancelPendingNetworkAutoComplete(); + CancelPendingCommandListInitialization(); + } + + private async Task ObserveConsoleCommandProcessingAsync(Task processingTask, CancellationToken cancellationToken) + { + try + { + await processingTask; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception e) + { + Log.Warn(e.ToString()); + } + finally + { + lock (consoleCommandProcessingLock) + { + if (ReferenceEquals(consoleCommandProcessingTask, processingTask)) + consoleCommandProcessingTask = null; + } + } + } + + private async Task ProcessConsoleMessagesAsync(ChannelReader channelReader, CancellationToken cancellationToken) + { + await foreach (string message in channelReader.ReadAllAsync(cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + return; + + if (TryParseBasicIoAutocompleteRequest(message, out _)) + await HandleBasicIoAutocompleteRequestAsync(message, cancellationToken); + else + await InvokeOnMainThreadAsync(() => HandleCommandPromptText(message)); + } + } + + private async Task HandleBasicIoAutocompleteRequestAsync(string text, CancellationToken cancellationToken) + { + try + { + string[] command = text[1..].Split((char)0x00); + if (command.Length < 2 || !command[0].Equals("autocomplete", StringComparison.OrdinalIgnoreCase)) + return; + + await WaitForCommandListInitializationAsync(cancellationToken); + + Task requestTask = InvokeRequired + ? await InvokeOnMainThreadAsync(() => BeginNetworkAutoCompleteRequest(command[1])) + : BeginNetworkAutoCompleteRequest(command[1]); + + await requestTask.WaitAsync(cancellationToken); + + if (command.Length > 1) + ConsoleIO.WriteLine((char)0x00 + "autocomplete" + (char)0x00 + ConsoleIO.AutoCompleteResult); + else ConsoleIO.WriteLine((char)0x00 + "autocomplete" + (char)0x00); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (OperationCanceledException) + { + } + } + + private static bool TryParseBasicIoAutocompleteRequest(string text, out string behindCursor) + { + behindCursor = string.Empty; + + if (!ConsoleIO.BasicIO || string.IsNullOrEmpty(text) || text[0] != (char)0x00) + return false; + + string[] command = text[1..].Split((char)0x00); + if (command.Length < 2 || !command[0].Equals("autocomplete", StringComparison.OrdinalIgnoreCase)) + return false; + + behindCursor = command[1]; + return true; + } + + private Task BeginNetworkAutoCompleteRequest(string behindCursor) + { + if (string.IsNullOrEmpty(behindCursor)) + return Task.FromResult(Array.Empty()); + + TaskCompletionSource request = new(TaskCreationOptions.RunContinuationsAsynchronously); + lock (networkAutoCompleteLock) + { + pendingNetworkAutoCompleteRequest?.TrySetException(new OperationCanceledException()); + pendingNetworkAutoCompleteRequest = request; + } + + try + { + if (handler.AutoComplete(behindCursor) < 0) + { + CompletePendingNetworkAutoComplete(Array.Empty()); + } + } + catch (Exception e) + { + lock (networkAutoCompleteLock) + { + if (ReferenceEquals(pendingNetworkAutoCompleteRequest, request)) + pendingNetworkAutoCompleteRequest = null; + } + request.TrySetException(e); + } + + return request.Task; + } + + private void BeginCommandListInitialization() + { + lock (networkAutoCompleteLock) + { + pendingCommandListInitialization?.TrySetCanceled(); + pendingCommandListInitialization = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + private void CompletePendingNetworkAutoComplete(string[] result) + { + TaskCompletionSource? pendingRequest; + lock (networkAutoCompleteLock) + { + pendingRequest = pendingNetworkAutoCompleteRequest; + pendingNetworkAutoCompleteRequest = null; + } + + pendingRequest?.TrySetResult(result); + } + + private void CancelPendingNetworkAutoComplete() + { + TaskCompletionSource? pendingRequest; + lock (networkAutoCompleteLock) + { + pendingRequest = pendingNetworkAutoCompleteRequest; + pendingNetworkAutoCompleteRequest = null; + } + + pendingRequest?.TrySetCanceled(); + } + + private void CompletePendingCommandListInitialization() + { + TaskCompletionSource? pendingInitialization; + lock (networkAutoCompleteLock) + { + pendingInitialization = pendingCommandListInitialization; + pendingCommandListInitialization = null; + } + + pendingInitialization?.TrySetResult(true); + } + + private void CancelPendingCommandListInitialization() + { + TaskCompletionSource? pendingInitialization; + lock (networkAutoCompleteLock) + { + pendingInitialization = pendingCommandListInitialization; + pendingCommandListInitialization = null; + } + + pendingInitialization?.TrySetCanceled(); + } + + private async Task WaitForCommandListInitializationAsync(CancellationToken cancellationToken) + { + Task? initializationTask; + lock (networkAutoCompleteLock) + { + initializationTask = pendingCommandListInitialization?.Task; + } + + if (initializationTask is null) + return; + + try + { + await initializationTask.WaitAsync(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (TimeoutException) + { + } + catch (OperationCanceledException) + { + } + } + /// /// Disconnect the client from the server (initiated from MCC) /// @@ -781,6 +1054,7 @@ namespace MinecraftClient botsOnHold.Clear(); botsOnHold.AddRange(bots); + StopConsoleHandlers(); if (handler is not null) { @@ -788,12 +1062,6 @@ namespace MinecraftClient handler.Dispose(); } - if (cmdprompt is not null) - { - cmdprompt.Cancel(); - cmdprompt = null; - } - if (timeoutdetector is not null) { timeoutdetector.Item2.Cancel(); @@ -820,8 +1088,7 @@ namespace MinecraftClient if (timeoutdetector is not null) { - if (timeoutdetector is not null && Thread.CurrentThread != timeoutdetector.Item1) - timeoutdetector.Item2.Cancel(); + timeoutdetector.Item2.Cancel(); timeoutdetector = null; } @@ -872,9 +1139,7 @@ namespace MinecraftClient if (!will_restart) { - ConsoleIO.Backend.StopReadThread(); - ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived; - ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler; + StopConsoleHandlers(); Program.HandleFailure(null, false, reason); } } @@ -885,16 +1150,18 @@ namespace MinecraftClient private void ConsoleReaderOnMessageReceived(object? sender, string e) { + Channel? activeChannel; + lock (consoleCommandProcessingLock) + { + activeChannel = consoleCommandChannel; + } - if (client.Client is null) + if (activeChannel is null || client.Client is null) return; if (client.Client.Connected) { - new Thread(() => - { - InvokeOnMainThread(() => HandleCommandPromptText(e)); - }).Start(); + activeChannel.Writer.TryWrite(e); } else return; @@ -916,55 +1183,44 @@ namespace MinecraftClient { if (ConsoleIO.BasicIO && text.Length > 0 && text[0] == (char)0x00) { - //Process a request from the GUI - string[] command = text[1..].Split((char)0x00); - switch (command[0].ToLower()) - { - case "autocomplete": - int id = handler.AutoComplete(command[1]); - while (!ConsoleIO.AutoCompleteDone) { Thread.Sleep(100); } - if (command.Length > 1) { ConsoleIO.WriteLine((char)0x00 + "autocomplete" + (char)0x00 + ConsoleIO.AutoCompleteResult); } - else ConsoleIO.WriteLine((char)0x00 + "autocomplete" + (char)0x00); - break; - } + _ = HandleBasicIoAutocompleteRequestAsync(text, CancellationToken.None); + return; } - else - { - text = text.Trim(); - if (text.Length > 1 - && Config.Main.Advanced.InternalCmdChar == MainConfigHelper.MainConfig.AdvancedConfig.InternalCmdCharType.none - && text[0] == '/') + text = text.Trim(); + + if (text.Length > 1 + && Config.Main.Advanced.InternalCmdChar == MainConfigHelper.MainConfig.AdvancedConfig.InternalCmdCharType.none + && text[0] == '/') + { + SendText(text); + } + else if (text.Length > 2 + && Config.Main.Advanced.InternalCmdChar != MainConfigHelper.MainConfig.AdvancedConfig.InternalCmdCharType.none + && text[0] == Config.Main.Advanced.InternalCmdChar.ToChar() + && text[1] == '/') + { + SendText(text[1..]); + } + else if (text.Length > 0) + { + if (Config.Main.Advanced.InternalCmdChar == MainConfigHelper.MainConfig.AdvancedConfig.InternalCmdCharType.none + || text[0] == Config.Main.Advanced.InternalCmdChar.ToChar()) { - SendText(text); - } - else if (text.Length > 2 - && Config.Main.Advanced.InternalCmdChar != MainConfigHelper.MainConfig.AdvancedConfig.InternalCmdCharType.none - && text[0] == Config.Main.Advanced.InternalCmdChar.ToChar() - && text[1] == '/') - { - SendText(text[1..]); - } - else if (text.Length > 0) - { - if (Config.Main.Advanced.InternalCmdChar == MainConfigHelper.MainConfig.AdvancedConfig.InternalCmdCharType.none - || text[0] == Config.Main.Advanced.InternalCmdChar.ToChar()) - { - CmdResult result = new(); - string command = Config.Main.Advanced.InternalCmdChar.ToChar() == ' ' ? text : text[1..]; - if (!PerformInternalCommand(Config.AppVar.ExpandVars(command), ref result, Settings.Config.AppVar.GetVariables()) && Config.Main.Advanced.InternalCmdChar.ToChar() == '/') - { - SendText(text); - } - else if (result.status != CmdResult.Status.NotRun && (result.status != CmdResult.Status.Done || !string.IsNullOrWhiteSpace(result.result))) - { - Log.Info(result); - } - } - else + CmdResult result = new(); + string command = Config.Main.Advanced.InternalCmdChar.ToChar() == ' ' ? text : text[1..]; + if (!PerformInternalCommand(Config.AppVar.ExpandVars(command), ref result, Settings.Config.AppVar.GetVariables()) && Config.Main.Advanced.InternalCmdChar.ToChar() == '/') { SendText(text); } + else if (result.status != CmdResult.Status.NotRun && (result.status != CmdResult.Status.Done || !string.IsNullOrWhiteSpace(result.result))) + { + Log.Info(result); + } + } + else + { + SendText(text); } } } @@ -1099,19 +1355,7 @@ namespace MinecraftClient /// Type of the return value public T InvokeOnMainThread(Func task) { - if (!InvokeRequired) - { - return task(); - } - else - { - TaskWithResult taskWithResult = new(task); - lock (threadTasksLock) - { - threadTasks.Enqueue(taskWithResult.ExecuteSynchronously); - } - return taskWithResult.WaitGetResult(); - } + return InvokeOnMainThreadAsync(task).GetAwaiter().GetResult(); } /// @@ -1126,6 +1370,37 @@ namespace MinecraftClient InvokeOnMainThread(() => { task(); return true; }); } + private Task InvokeOnMainThreadAsync(Func task) + { + if (!InvokeRequired) + { + try + { + return Task.FromResult(task()); + } + catch (Exception e) + { + return Task.FromException(e); + } + } + + TaskWithResult taskWithResult = new(task); + lock (threadTasksLock) + { + threadTasks.Enqueue(taskWithResult); + } + return taskWithResult.AsTask(); + } + + private Task InvokeOnMainThreadAsync(Action task) + { + return InvokeOnMainThreadAsync(() => + { + task(); + return true; + }); + } + /// /// Clear all tasks /// @@ -1133,7 +1408,8 @@ namespace MinecraftClient { lock (threadTasksLock) { - threadTasks.Clear(); + while (threadTasks.Count > 0) + threadTasks.Dequeue().Cancel(); } } @@ -1145,16 +1421,13 @@ namespace MinecraftClient { get { - int callingThreadId = Environment.CurrentManagedThreadId; - if (handler is not null) - { - return handler.GetNetMainThreadId() != callingThreadId; - } - else + if (handler is null) { // net read thread (main thread) not yet ready return false; } + + return !MainThreadExecutionScope.IsActive(this); } } @@ -3040,6 +3313,7 @@ namespace MinecraftClient DispatchBotEvent(bot => bot.AfterGameJoined()); + BeginCommandListInitialization(); ConsoleIO.InitCommandList(dispatcher); } @@ -4445,6 +4719,8 @@ namespace MinecraftClient public void OnAutoCompleteDone(int transactionId, string[] result) { ConsoleIO.OnAutoCompleteDone(transactionId, result); + CompletePendingNetworkAutoComplete(result); + CompletePendingCommandListInitialization(); } public void SetCanSendMessage(bool canSendMessage) diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs index 58a42b9b..a61437dc 100644 --- a/MinecraftClient/Program.cs +++ b/MinecraftClient/Program.cs @@ -661,7 +661,7 @@ namespace MinecraftClient SessionCache.Store(loginLower, session); if (result == ProtocolHandler.LoginResult.Success) - session.SessionPreCheckTask = Task.Factory.StartNew(() => session.SessionPreCheck(Config.Main.General.AccountType)); + session.SessionPreCheckTask = session.SessionPreCheckAsync(Config.Main.General.AccountType); } if (result == ProtocolHandler.LoginResult.Success) diff --git a/MinecraftClient/Protocol/Handlers/DataTypes.cs b/MinecraftClient/Protocol/Handlers/DataTypes.cs index bdbdecea..578691fb 100644 --- a/MinecraftClient/Protocol/Handlers/DataTypes.cs +++ b/MinecraftClient/Protocol/Handlers/DataTypes.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; +using System.Threading; +using System.Threading.Tasks; using MinecraftClient.Inventory; using MinecraftClient.Inventory.ItemPalettes; using MinecraftClient.Mapping; @@ -313,6 +315,27 @@ namespace MinecraftClient.Protocol.Handlers return i; } + /// + /// Read an integer from the network asynchronously. + /// + /// The integer + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public async Task ReadNextVarIntRAWAsync(SocketWrapper socket, CancellationToken cancellationToken) + { + int i = 0; + int j = 0; + byte b; + while (true) + { + b = (await socket.ReadDataRAWAsync(1, cancellationToken))[0]; + i |= (b & 0x7F) << j++ * 7; + if (j > 5) throw new OverflowException("VarInt too big"); + if ((b & 0x80) != 128) break; + } + + return i; + } + /// /// Read an integer from a cache of bytes and remove it from the cache /// diff --git a/MinecraftClient/Protocol/Handlers/Protocol16.cs b/MinecraftClient/Protocol/Handlers/Protocol16.cs index 6777200d..9f8ad701 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol16.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol16.cs @@ -7,6 +7,7 @@ using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Threading; +using System.Threading.Tasks; using MinecraftClient.Crypto; using MinecraftClient.Inventory; using MinecraftClient.Mapping; @@ -29,7 +30,9 @@ namespace MinecraftClient.Protocol.Handlers readonly IMinecraftComHandler handler; private bool encrypted = false; private readonly int protocolversion; - private Tuple? netRead = null; + private Task? netReadTask; + private CancellationTokenSource? netReadCancellationTokenSource; + private int netReadThreadId = -1; Crypto.AesCfb8Stream? s; readonly TcpClient c; @@ -69,15 +72,15 @@ namespace MinecraftClient.Protocol.Handlers c = Client; } - private void Updater(object? o) + private void Updater(CancellationToken cancelToken) { - var cancelToken = (CancellationToken)o!; - if (cancelToken.IsCancellationRequested) return; try { + netReadThreadId = Environment.CurrentManagedThreadId; + using IDisposable _ = MainThreadExecutionScope.Enter(handler); Stopwatch stopWatch = Stopwatch.StartNew(); long nextUpdateDue = 0; @@ -104,6 +107,8 @@ namespace MinecraftClient.Protocol.Handlers catch (SocketException) { } catch (ObjectDisposedException) { } catch (OperationCanceledException) { } + catch (Exception) { } + finally { netReadThreadId = -1; } if (cancelToken.IsCancellationRequested) return; @@ -240,9 +245,13 @@ namespace MinecraftClient.Protocol.Handlers private void StartUpdating() { - netRead = new(new Thread(new ParameterizedThreadStart(Updater)), new CancellationTokenSource()); - netRead.Item1.Name = "ProtocolPacketHandler"; - netRead.Item1.Start(netRead.Item2.Token); + CancellationTokenSource netReadCts = new(); + netReadCancellationTokenSource = netReadCts; + netReadTask = Task.Factory.StartNew( + () => Updater(netReadCts.Token), + netReadCts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); } /// @@ -251,7 +260,7 @@ namespace MinecraftClient.Protocol.Handlers /// Net read thread ID public int GetNetMainThreadId() { - return netRead is not null ? netRead.Item1.ManagedThreadId : -1; + return netReadThreadId; } public bool SendCookieResponse(string name, byte[]? data) @@ -268,9 +277,9 @@ namespace MinecraftClient.Protocol.Handlers { try { - if (netRead is not null) + if (netReadCancellationTokenSource is not null) { - netRead.Item2.Cancel(); + netReadCancellationTokenSource.Cancel(); c.Close(); } } @@ -519,7 +528,8 @@ namespace MinecraftClient.Protocol.Handlers Receive(pid, 0, 1, SocketFlags.None); while (pid[0] == 0xFA) //Skip some early plugin messages { - ProcessPacket(pid[0]); + using (MainThreadExecutionScope.Enter(handler)) + ProcessPacket(pid[0]); Receive(pid, 0, 1, SocketFlags.None); } if (pid[0] == 0xFD) @@ -559,8 +569,7 @@ namespace MinecraftClient.Protocol.Handlers if (session.ServerPublicKey is not null && session.SessionPreCheckTask is not null && serverIDhash == session.ServerIDhash && Enumerable.SequenceEqual(serverPublicKey, session.ServerPublicKey)) { - session.SessionPreCheckTask.Wait(); - if (session.SessionPreCheckTask.Result) // PreCheck Successed + if (session.SessionPreCheckTask.IsCompletedSuccessfully && session.SessionPreCheckTask.Result) needCheckSession = false; } @@ -633,7 +642,8 @@ namespace MinecraftClient.Protocol.Handlers Receive(pid, 0, 1, SocketFlags.None); while (pid[0] >= 0xC0 && pid[0] != 0xFF) //Skip some early packets or plugin messages { - ProcessPacket(pid[0]); + using (MainThreadExecutionScope.Enter(handler)) + ProcessPacket(pid[0]); Receive(pid, 0, 1, SocketFlags.None); } if (pid[0] == (byte)1) diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 21d37c88..bb31fa84 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -9,6 +9,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; +using System.Threading.Tasks; using MinecraftClient.Crypto; using MinecraftClient.Inventory; using MinecraftClient.Inventory.ItemPalettes; @@ -117,8 +118,11 @@ namespace MinecraftClient.Protocol.Handlers readonly PacketTypePalette packetPalette; readonly SocketWrapper socketWrapper; readonly DataTypes dataTypes; - Tuple? netMain = null; // main thread - Tuple? netReader = null; // reader thread + private Task? netMainTask; + private CancellationTokenSource? netMainCancellationTokenSource; + private int netMainThreadId = -1; + private Task? netReaderTask; + private CancellationTokenSource? netReaderCancellationTokenSource; readonly ILogger log; readonly RandomNumberGenerator randomGen; private bool legacyAchievementsInitialized; @@ -278,17 +282,17 @@ namespace MinecraftClient.Protocol.Handlers } /// - /// Separate thread. Network reading loop. + /// Serialized packet/tick loop. /// - private void Updater(object? o) + private void Updater(CancellationToken cancelToken) { - var cancelToken = (CancellationToken)o!; - if (cancelToken.IsCancellationRequested) return; try { + netMainThreadId = Environment.CurrentManagedThreadId; + using IDisposable _ = MainThreadExecutionScope.Enter(handler); Stopwatch stopWatch = Stopwatch.StartNew(); long nextUpdateDue = 0; while (!packetQueue.IsAddingCompleted) @@ -330,6 +334,13 @@ namespace MinecraftClient.Protocol.Handlers catch (System.IO.IOException) { } + catch (Exception) + { + } + finally + { + netMainThreadId = -1; + } if (cancelToken.IsCancellationRequested) return; @@ -340,20 +351,13 @@ namespace MinecraftClient.Protocol.Handlers /// /// Read and decompress packets. /// - internal void PacketReader(object? o) + internal async Task PacketReaderAsync(CancellationToken cancelToken) { - var cancelToken = (CancellationToken)o!; - while (socketWrapper.IsConnected() && !cancelToken.IsCancellationRequested) + while (!cancelToken.IsCancellationRequested) { try { - while (socketWrapper.HasDataAvailable()) - { - packetQueue.Add(ReadNextPacket(), cancelToken); - - if (cancelToken.IsCancellationRequested) - break; - } + packetQueue.Add(await ReadNextPacketAsync(cancelToken), cancelToken); } catch (OperationCanceledException) { @@ -375,11 +379,10 @@ namespace MinecraftClient.Protocol.Handlers { break; } - - if (cancelToken.IsCancellationRequested) + catch (Exception) + { break; - - Thread.Sleep(10); + } } packetQueue.CompleteAdding(); @@ -415,6 +418,30 @@ namespace MinecraftClient.Protocol.Handlers return new(packetId, packetData); } + internal async Task>> ReadNextPacketAsync(CancellationToken cancellationToken) + { + var size = await dataTypes.ReadNextVarIntRAWAsync(socketWrapper, cancellationToken); //Packet size + Queue packetData = new(await socketWrapper.ReadDataRAWAsync(size, cancellationToken)); //Packet contents + + if (protocolVersion >= MC_1_8_Version + && compression_treshold >= 0) + { + var sizeUncompressed = dataTypes.ReadNextVarInt(packetData); + if (sizeUncompressed != 0) + { + var toDecompress = packetData.ToArray(); + var uncompressed = ZlibUtils.Decompress(toDecompress, sizeUncompressed); + packetData = new Queue(uncompressed); + } + } + + var packetId = dataTypes.ReadNextVarInt(packetData); + if (handler.GetNetworkPacketCaptureEnabled()) + handler.OnNetworkPacket(packetId, packetData.ToList(), currentState == CurrentState.Login, true); + + return new(packetId, packetData); + } + /// /// Handle the given packet /// @@ -3844,19 +3871,17 @@ namespace MinecraftClient.Protocol.Handlers /// private void StartUpdating() { - Thread threadUpdater = new(new ParameterizedThreadStart(Updater)) - { - Name = "ProtocolPacketHandler" - }; - netMain = new Tuple(threadUpdater, new CancellationTokenSource()); - threadUpdater.Start(netMain.Item2.Token); + CancellationTokenSource netMainCts = new(); + netMainCancellationTokenSource = netMainCts; + netMainTask = Task.Factory.StartNew( + () => Updater(netMainCts.Token), + netMainCts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); - Thread threadReader = new(new ParameterizedThreadStart(PacketReader)) - { - Name = "ProtocolPacketReader" - }; - netReader = new Tuple(threadReader, new CancellationTokenSource()); - threadReader.Start(netReader.Item2.Token); + CancellationTokenSource netReaderCts = new(); + netReaderCancellationTokenSource = netReaderCts; + netReaderTask = PacketReaderAsync(netReaderCts.Token); } /// @@ -3865,7 +3890,7 @@ namespace MinecraftClient.Protocol.Handlers /// Net read thread ID public int GetNetMainThreadId() { - return netMain is not null ? netMain.Item1.ManagedThreadId : -1; + return netMainThreadId; } /// @@ -3875,14 +3900,14 @@ namespace MinecraftClient.Protocol.Handlers { try { - if (netMain is not null) + if (netMainCancellationTokenSource is not null) { - netMain.Item2.Cancel(); + netMainCancellationTokenSource.Cancel(); } - if (netReader is not null) + if (netReaderCancellationTokenSource is not null) { - netReader.Item2.Cancel(); + netReaderCancellationTokenSource.Cancel(); socketWrapper.Disconnect(); } } @@ -4106,7 +4131,8 @@ namespace MinecraftClient.Protocol.Handlers return true; //No need to check session or start encryption } default: - HandlePacket(packetId, packetData); + using (MainThreadExecutionScope.Enter(handler)) + HandlePacket(packetId, packetData); break; } } @@ -4133,8 +4159,7 @@ namespace MinecraftClient.Protocol.Handlers && serverIDhash == session.ServerIDhash && serverPublicKey.SequenceEqual(session.ServerPublicKey)) { - session.SessionPreCheckTask.Wait(); - if (session.SessionPreCheckTask.Result) // PreCheck Success + if (session.SessionPreCheckTask.IsCompletedSuccessfully && session.SessionPreCheckTask.Result) needCheckSession = false; } @@ -4256,7 +4281,8 @@ namespace MinecraftClient.Protocol.Handlers return true; } default: - HandlePacket(packetId, packetData); + using (MainThreadExecutionScope.Enter(handler)) + HandlePacket(packetId, packetData); break; } } diff --git a/MinecraftClient/Protocol/Handlers/SocketWrapper.cs b/MinecraftClient/Protocol/Handlers/SocketWrapper.cs index a4f451b1..6931abdf 100644 --- a/MinecraftClient/Protocol/Handlers/SocketWrapper.cs +++ b/MinecraftClient/Protocol/Handlers/SocketWrapper.cs @@ -1,5 +1,8 @@ using System; +using System.IO; using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; using MinecraftClient.Crypto; namespace MinecraftClient.Protocol.Handlers @@ -68,6 +71,22 @@ namespace MinecraftClient.Protocol.Handlers } } + private async Task ReceiveAsync(Memory buffer, CancellationToken cancellationToken) + { + int read = 0; + while (read < buffer.Length) + { + int currentRead = encrypted + ? await s!.ReadAsync(buffer[read..], cancellationToken) + : await c.GetStream().ReadAsync(buffer[read..], cancellationToken); + + if (currentRead == 0) + throw new IOException("Connection closed."); + + read += currentRead; + } + } + /// /// Read some data from the server. /// @@ -84,6 +103,18 @@ namespace MinecraftClient.Protocol.Handlers return Array.Empty(); } + public async Task ReadDataRAWAsync(int length, CancellationToken cancellationToken) + { + if (length > 0) + { + byte[] cache = new byte[length]; + await ReceiveAsync(cache, cancellationToken); + return cache; + } + + return Array.Empty(); + } + /// /// Send raw data to the server. /// @@ -99,6 +130,17 @@ namespace MinecraftClient.Protocol.Handlers c.Client.Send(buffer); } + public async Task SendDataRAWAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + if (!IsConnected()) + throw new SocketException((int)SocketError.NotConnected); + + if (encrypted) + await s!.WriteAsync(buffer, cancellationToken); + else + await c.GetStream().WriteAsync(buffer, cancellationToken); + } + /// /// Disconnect from the server /// diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index e0954ee2..27fe7665 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -7,6 +7,7 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using static MinecraftClient.Settings; @@ -231,6 +232,8 @@ namespace MinecraftClient.Protocol.Message /// Specify whether translation rules have been loaded /// private static bool RulesInitialized = false; + private static readonly Lock RulesInitializationLock = new(); + private static Task? RulesRefreshTask = null; /// /// Set of translation rules for formatting text @@ -243,23 +246,25 @@ namespace MinecraftClient.Protocol.Message /// public static void InitTranslations() { - if (!RulesInitialized) + lock (RulesInitializationLock) { - InitRules(); + if (RulesInitialized) + return; + RulesInitialized = true; + RulesRefreshTask = InitRulesAsync(); + _ = ObserveInitRulesAsync(RulesRefreshTask); } } /// - /// Internal rule initialization method. Looks for local rule file or download it from Mojang asset servers. + /// Internal rule initialization method. Looks for local rule file and refreshes it from Mojang asset servers if needed. /// - private static void InitRules() + private static async Task InitRulesAsync() { if (Config.Main.Advanced.Language == "en_us") { - TranslationRules = - JsonSerializer.Deserialize>( - (byte[])MinecraftAssets.ResourceManager.GetObject("en_us.json")!)!; + TranslationRules = LoadEmbeddedTranslationRules(); return; } @@ -269,21 +274,9 @@ namespace MinecraftClient.Protocol.Message string languageFilePath = "lang" + Path.DirectorySeparatorChar + Config.Main.Advanced.Language + ".json"; - // Load the external dictionary of translation rules or display an error message - if (File.Exists(languageFilePath)) - { - try - { - TranslationRules = - JsonSerializer.Deserialize>(File.OpenRead(languageFilePath))!; - } - catch (IOException) - { - } - catch (JsonException) - { - } - } + if (TryLoadTranslationRulesFromFile(languageFilePath, out Dictionary? translationRules)) + TranslationRules = translationRules; + else TranslationRules = LoadEmbeddedTranslationRules(); if (TranslationRules.TryGetValue("Version", out string? version) && version == Settings.TranslationsFile_Version) @@ -296,14 +289,12 @@ namespace MinecraftClient.Protocol.Message // Try downloading language file from Mojang's servers? ConsoleIO.WriteLineFormatted( "§8" + string.Format(Translations.chat_download, Config.Main.Advanced.Language)); - HttpClient httpClient = new(); + using HttpClient httpClient = new(); try { - Task fetch_index = httpClient.GetStringAsync(TranslationsFile_Website_Index); - fetch_index.Wait(); - Match match = Regex.Match(fetch_index.Result, + string fetchIndex = await httpClient.GetStringAsync(TranslationsFile_Website_Index); + Match match = Regex.Match(fetchIndex, $"minecraft/lang/{Config.Main.Advanced.Language}.json" + @""":\s\{""hash"":\s""([\d\w]{40})"""); - fetch_index.Dispose(); if (match.Success && match.Groups.Count == 2) { string hash = match.Groups[1].Value; @@ -312,22 +303,19 @@ namespace MinecraftClient.Protocol.Message ConsoleIO.WriteLineFormatted( string.Format(Translations.chat_request, translation_file_location)); - Task?> fetckFileTask = - httpClient.GetFromJsonAsync>(translation_file_location); - fetckFileTask.Wait(); - if (fetckFileTask.Result is not null && fetckFileTask.Result.Count > 0) + Dictionary? fetchedFile = + await httpClient.GetFromJsonAsync>(translation_file_location); + if (fetchedFile is not null && fetchedFile.Count > 0) { - TranslationRules = fetckFileTask.Result; + TranslationRules = fetchedFile; TranslationRules["Version"] = TranslationsFile_Version; - File.WriteAllText(languageFilePath, + await File.WriteAllTextAsync(languageFilePath, JsonSerializer.Serialize(TranslationRules, typeof(Dictionary)), Encoding.UTF8); ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.chat_done, languageFilePath)); return; } - - fetckFileTask.Dispose(); } else { @@ -350,17 +338,52 @@ namespace MinecraftClient.Protocol.Message if (Config.Logging.DebugMessages && !string.IsNullOrEmpty(e.StackTrace)) ConsoleIO.WriteLine(e.StackTrace); } - finally - { - httpClient.Dispose(); - } - - TranslationRules = - JsonSerializer.Deserialize>( - (byte[])MinecraftAssets.ResourceManager.GetObject("en_us.json")!)!; + TranslationRules = LoadEmbeddedTranslationRules(); ConsoleIO.WriteLine(Translations.chat_use_default); } + private static async Task ObserveInitRulesAsync(Task initRulesTask) + { + try + { + await initRulesTask; + } + catch (Exception e) + { + TranslationRules = LoadEmbeddedTranslationRules(); + if (Config.Logging.DebugMessages) + ConsoleIO.WriteLine(e.ToString()); + } + } + + private static Dictionary LoadEmbeddedTranslationRules() + { + return JsonSerializer.Deserialize>( + (byte[])MinecraftAssets.ResourceManager.GetObject("en_us.json")!)!; + } + + private static bool TryLoadTranslationRulesFromFile(string languageFilePath, out Dictionary? translationRules) + { + translationRules = null; + if (!File.Exists(languageFilePath)) + return false; + + try + { + translationRules = + JsonSerializer.Deserialize>(File.OpenRead(languageFilePath))!; + return translationRules is not null; + } + catch (IOException) + { + return false; + } + catch (JsonException) + { + return false; + } + } + public static string? TranslateString(string rulename) { if (TranslationRules.TryGetValue(rulename, out string? result)) @@ -617,4 +640,4 @@ namespace MinecraftClient.Protocol.Message return formatting + message + extraBuilder.ToString(); } } -} \ No newline at end of file +} diff --git a/MinecraftClient/Protocol/MicrosoftAuthentication.cs b/MinecraftClient/Protocol/MicrosoftAuthentication.cs index 4c84ce47..1389fd88 100644 --- a/MinecraftClient/Protocol/MicrosoftAuthentication.cs +++ b/MinecraftClient/Protocol/MicrosoftAuthentication.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Runtime.InteropServices; using System.Threading; +using System.Threading.Tasks; namespace MinecraftClient.Protocol { @@ -37,7 +38,13 @@ namespace MinecraftClient.Protocol { string postData = "client_id={0}&grant_type=authorization_code&redirect_uri=https%3A%2F%2Fmccteam.github.io%2Fredirect.html&code={1}"; postData = string.Format(postData, clientId, code); - return RequestToken(postData); + return RequestTokenAsync(postData).GetAwaiter().GetResult(); + } + + public static Task RequestAccessTokenAsync(string code) + { + string postData = "client_id={0}&grant_type=authorization_code&redirect_uri=https%3A%2F%2Fmccteam.github.io%2Fredirect.html&code={1}"; + return RequestTokenAsync(string.Format(postData, clientId, code)); } /// @@ -49,7 +56,13 @@ namespace MinecraftClient.Protocol { string postData = "client_id={0}&grant_type=refresh_token&redirect_uri=https%3A%2F%2Fmccteam.github.io%2Fredirect.html&refresh_token={1}"; postData = string.Format(postData, clientId, refreshToken); - return RequestToken(postData); + return RequestTokenAsync(postData).GetAwaiter().GetResult(); + } + + public static Task RefreshAccessTokenAsync(string refreshToken) + { + string postData = "client_id={0}&grant_type=refresh_token&redirect_uri=https%3A%2F%2Fmccteam.github.io%2Fredirect.html&refresh_token={1}"; + return RequestTokenAsync(string.Format(postData, clientId, refreshToken)); } /// @@ -58,6 +71,11 @@ namespace MinecraftClient.Protocol /// /// Device code response for user to complete authentication public static DeviceCodeResponse RequestDeviceCode() + { + return RequestDeviceCodeAsync().GetAwaiter().GetResult(); + } + + public static async Task RequestDeviceCodeAsync(CancellationToken cancellationToken = default) { string postData = string.Format("client_id={0}&scope=XboxLive.signin%20offline_access%20openid%20email", clientId); @@ -65,7 +83,7 @@ namespace MinecraftClient.Protocol { UserAgent = "MCC/" + Program.Version }; - var response = request.Post("application/x-www-form-urlencoded", postData); + var response = await request.PostAsync("application/x-www-form-urlencoded", postData, cancellationToken); var jsonData = Json.ParseJson(response.Body); if (jsonData?["error"] is not null) @@ -93,6 +111,11 @@ namespace MinecraftClient.Protocol /// Polling interval in seconds /// Login response with access token and refresh token public static LoginResponse PollDeviceCodeToken(string deviceCode, int expiresIn, int interval) + { + return PollDeviceCodeTokenAsync(deviceCode, expiresIn, interval).GetAwaiter().GetResult(); + } + + public static async Task PollDeviceCodeTokenAsync(string deviceCode, int expiresIn, int interval, CancellationToken cancellationToken = default) { // Per OAuth 2.0 device code spec, server may respond with "slow_down" requiring // the client to increase its polling interval by this amount @@ -107,13 +130,13 @@ namespace MinecraftClient.Protocol while (stopwatch.Elapsed.TotalSeconds < expiresIn) { - Thread.Sleep(pollInterval * 1000); + await Task.Delay(TimeSpan.FromSeconds(pollInterval), cancellationToken); var request = new ProxiedWebRequest(tokenUrl) { UserAgent = "MCC/" + Program.Version }; - var response = request.Post("application/x-www-form-urlencoded", postData); + var response = await request.PostAsync("application/x-www-form-urlencoded", postData, cancellationToken); var jsonData = Json.ParseJson(response.Body); if (jsonData?["error"] is not null) @@ -173,12 +196,17 @@ namespace MinecraftClient.Protocol /// Complete POST data for the request /// private static LoginResponse RequestToken(string postData) + { + return RequestTokenAsync(postData).GetAwaiter().GetResult(); + } + + private static async Task RequestTokenAsync(string postData, CancellationToken cancellationToken = default) { var request = new ProxiedWebRequest(tokenUrl) { UserAgent = "MCC/" + Program.Version }; - var response = request.Post("application/x-www-form-urlencoded", postData); + var response = await request.PostAsync("application/x-www-form-urlencoded", postData, cancellationToken); var jsonData = Json.ParseJson(response.Body); // Error handling @@ -271,6 +299,11 @@ namespace MinecraftClient.Protocol /// /// public static XblAuthenticateResponse XblAuthenticate(Microsoft.LoginResponse loginResponse) + { + return XblAuthenticateAsync(loginResponse).GetAwaiter().GetResult(); + } + + public static async Task XblAuthenticateAsync(Microsoft.LoginResponse loginResponse, CancellationToken cancellationToken = default) { var request = new ProxiedWebRequest(xbl) { @@ -291,7 +324,7 @@ namespace MinecraftClient.Protocol + "\"RelyingParty\": \"http://auth.xboxlive.com\"," + "\"TokenType\": \"JWT\"" + "}"; - var response = request.Post("application/json", payload); + var response = await request.PostAsync("application/json", payload, cancellationToken); if (Settings.Config.Logging.DebugMessages) { ConsoleIO.WriteLine(response.ToString()); @@ -321,6 +354,11 @@ namespace MinecraftClient.Protocol /// /// public static XSTSAuthenticateResponse XSTSAuthenticate(XblAuthenticateResponse xblResponse) + { + return XSTSAuthenticateAsync(xblResponse).GetAwaiter().GetResult(); + } + + public static async Task XSTSAuthenticateAsync(XblAuthenticateResponse xblResponse, CancellationToken cancellationToken = default) { var request = new ProxiedWebRequest(xsts) { @@ -339,7 +377,7 @@ namespace MinecraftClient.Protocol + "\"RelyingParty\": \"rp://api.minecraftservices.com/\"," + "\"TokenType\": \"JWT\"" + "}"; - var response = request.Post("application/json", payload); + var response = await request.PostAsync("application/json", payload, cancellationToken); if (Settings.Config.Logging.DebugMessages) { ConsoleIO.WriteLine(response.ToString()); @@ -404,6 +442,11 @@ namespace MinecraftClient.Protocol /// /// public static string LoginWithXbox(string userHash, string xstsToken) + { + return LoginWithXboxAsync(userHash, xstsToken).GetAwaiter().GetResult(); + } + + public static async Task LoginWithXboxAsync(string userHash, string xstsToken, CancellationToken cancellationToken = default) { var request = new ProxiedWebRequest(loginWithXbox) { @@ -411,7 +454,7 @@ namespace MinecraftClient.Protocol }; string payload = "{\"identityToken\": \"XBL3.0 x=" + userHash + ";" + xstsToken + "\"}"; - var response = request.Post("application/json", payload); + var response = await request.PostAsync("application/json", payload, cancellationToken); if (Settings.Config.Logging.DebugMessages) { @@ -430,10 +473,15 @@ namespace MinecraftClient.Protocol /// /// True if the user own the game public static bool UserHasGame(string accessToken) + { + return UserHasGameAsync(accessToken).GetAwaiter().GetResult(); + } + + public static async Task UserHasGameAsync(string accessToken, CancellationToken cancellationToken = default) { var request = new ProxiedWebRequest(ownership); request.Headers.Add("Authorization", string.Format("Bearer {0}", accessToken)); - var response = request.Get(); + var response = await request.GetAsync(cancellationToken); if (Settings.Config.Logging.DebugMessages) { @@ -446,10 +494,15 @@ namespace MinecraftClient.Protocol } public static UserProfile GetUserProfile(string accessToken) + { + return GetUserProfileAsync(accessToken).GetAwaiter().GetResult(); + } + + public static async Task GetUserProfileAsync(string accessToken, CancellationToken cancellationToken = default) { var request = new ProxiedWebRequest(profile); request.Headers.Add("Authorization", string.Format("Bearer {0}", accessToken)); - var response = request.Get(); + var response = await request.GetAsync(cancellationToken); if (Settings.Config.Logging.DebugMessages) { diff --git a/MinecraftClient/Protocol/ProtocolHandler.cs b/MinecraftClient/Protocol/ProtocolHandler.cs index a8568f38..c27d1d1b 100644 --- a/MinecraftClient/Protocol/ProtocolHandler.cs +++ b/MinecraftClient/Protocol/ProtocolHandler.cs @@ -7,6 +7,8 @@ using System.Net.Http; using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; using DnsClient; using MinecraftClient.Protocol.Handlers; using MinecraftClient.Protocol.Handlers.Forge; @@ -1108,6 +1110,43 @@ namespace MinecraftClient.Protocol } } + public static async Task SessionCheckAsync(string uuid, string accesstoken, string serverhash, LoginType type) + { + try + { + string jsonRequest = "{\"accessToken\":\"" + accesstoken + "\",\"selectedProfile\":\"" + uuid + + "\",\"serverId\":\"" + serverhash + "\"}"; + string host = type == LoginType.yggdrasil + ? Config.Main.General.AuthServer.Host + : "sessionserver.mojang.com"; + int port = type == LoginType.yggdrasil ? Config.Main.General.AuthServer.Port : 443; + string endpoint = type == LoginType.yggdrasil + ? Config.Main.General.AuthServer.AuthlibInjectorAPIPath + "/sessionserver/session/minecraft/join" + : "/session/minecraft/join"; + + bool useHttps = type == LoginType.yggdrasil ? Config.Main.General.AuthServer.UseHttps : true; + var response = await DoHTTPSRequestAsync( + HttpMethod.Post, + host, + port, + endpoint, + new Dictionary + { + { "Accept", "application/json" }, + { "Content-Type", "application/json" } + }, + jsonRequest, + useHttps, + CancellationToken.None); + + return response.StatusCode >= 200 && response.StatusCode < 300; + } + catch + { + return false; + } + } + /// /// Retrieve available Realms worlds of a player and display them /// @@ -1349,6 +1388,57 @@ namespace MinecraftClient.Protocol return statusCode; } + private static async Task<(int StatusCode, string Result)> DoHTTPSRequestAsync(HttpMethod method, string host, int port, string path, Dictionary headers, string? body, bool useHttps, CancellationToken cancellationToken) + { + if (Settings.Config.Logging.DebugMessages) + ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.debug_request, host)); + + using SocketsHttpHandler handler = new(); + handler.ConnectCallback = async (ctx, ct) => + { + TcpClient client = ProxyHandler.NewTcpClient(host, port, true); + return client.GetStream(); + }; + + using HttpClient client = new(handler); + + string scheme = useHttps ? "https" : "http"; + using HttpRequestMessage request = new(method, scheme + "://" + host + ":" + port + path); + + string contentType = "text/plain"; + foreach (var header in headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + if (header.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) + contentType = header.Value; + } + + if (body is not null) + request.Content = new StringContent(body, Encoding.UTF8, contentType); + + if (Settings.Config.Logging.DebugMessages) + ConsoleIO.WriteLineFormatted("§8> " + request); + + using CancellationTokenSource timeoutCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(30)); + + using HttpResponseMessage response = await client.SendAsync(request, timeoutCancellationTokenSource.Token); + int statusCode = (int)response.StatusCode; + string responseBody = statusCode == 204 + ? "No Content" + : await response.Content.ReadAsStringAsync(timeoutCancellationTokenSource.Token); + + if (Settings.Config.Logging.DebugMessages) + { + ConsoleIO.WriteLine(""); + foreach (string line in responseBody.Split('\n')) + ConsoleIO.WriteLineFormatted("§8< " + line); + } + + return (statusCode, responseBody); + } + /// /// Encode a string to a json string. /// Will convert special chars to \u0000 unicode escape sequences. @@ -1389,4 +1479,4 @@ namespace MinecraftClient.Protocol return dateTime; } } -} \ No newline at end of file +} diff --git a/MinecraftClient/Protocol/ProxiedWebRequest.cs b/MinecraftClient/Protocol/ProxiedWebRequest.cs index 220ac319..7911bc32 100644 --- a/MinecraftClient/Protocol/ProxiedWebRequest.cs +++ b/MinecraftClient/Protocol/ProxiedWebRequest.cs @@ -3,6 +3,8 @@ using System.Collections.Specialized; using System.Net; using System.Net.Http; using System.Text; +using System.Threading; +using System.Threading.Tasks; using MinecraftClient.Proxy; namespace MinecraftClient.Protocol @@ -72,6 +74,12 @@ namespace MinecraftClient.Protocol /// public Response Get() => Send(HttpMethod.Get); + /// + /// Perform GET request asynchronously. Proxy is handled automatically. + /// + public Task GetAsync(CancellationToken cancellationToken = default) => + SendAsync(HttpMethod.Get, cancellationToken: cancellationToken); + /// /// Perform POST request. Proxy is handled automatically. /// @@ -79,6 +87,14 @@ namespace MinecraftClient.Protocol /// Request body public Response Post(string contentType, string body) => Send(HttpMethod.Post, contentType, body); + /// + /// Perform POST request asynchronously. Proxy is handled automatically. + /// + /// The content type of request body + /// Request body + public Task PostAsync(string contentType, string body, CancellationToken cancellationToken = default) => + SendAsync(HttpMethod.Post, contentType, body, cancellationToken); + /// /// Send an HTTP request. Proxy is configured automatically from Settings. /// @@ -144,6 +160,66 @@ namespace MinecraftClient.Protocol } } + /// + /// Send an HTTP request asynchronously. Proxy is configured automatically from Settings. + /// + private async Task SendAsync(HttpMethod method, string? contentType = null, string? body = null, CancellationToken cancellationToken = default) + { + using var handler = CreateHandler(); + using var client = new HttpClient(handler); + + using var request = new HttpRequestMessage(method, _uri); + + foreach (string key in Headers) + { + if (key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || + key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) || + key.Equals("Host", StringComparison.OrdinalIgnoreCase)) + continue; + + request.Headers.TryAddWithoutValidation(key, Headers[key]); + } + + if (body is not null) + request.Content = new StringContent(body, Encoding.UTF8, contentType ?? "text/plain"); + + if (Debug) + { + ConsoleIO.WriteLine($"< {method} {_uri}"); + foreach (string key in Headers) + ConsoleIO.WriteLine($"< {key}: {Headers[key]}"); + } + + try + { + using var httpResponse = await client.SendAsync(request, cancellationToken); + string responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + var responseHeaders = new NameValueCollection(); + foreach (var header in httpResponse.Headers) + foreach (var val in header.Value) + responseHeaders.Add(header.Key.ToLowerInvariant(), val); + foreach (var header in httpResponse.Content.Headers) + foreach (var val in header.Value) + responseHeaders.Add(header.Key.ToLowerInvariant(), val); + + var cookies = new NameValueCollection(); + foreach (Cookie cookie in handler.CookieContainer.GetCookies(_uri)) + { + if (!cookie.Expired) + cookies.Add(cookie.Name, cookie.Value); + } + + return new Response((int)httpResponse.StatusCode, responseBody, responseHeaders, cookies); + } + catch (HttpRequestException ex) + { + if (Debug) + ConsoleIO.WriteLine("HTTP error: " + ex.Message); + return Response.Empty(); + } + } + /// /// Create a SocketsHttpHandler with proxy support from ProxyHandler settings. /// @@ -231,4 +307,4 @@ namespace MinecraftClient.Protocol } } } -} \ No newline at end of file +} diff --git a/MinecraftClient/Protocol/Session/SessionToken.cs b/MinecraftClient/Protocol/Session/SessionToken.cs index 1364012b..244c82a4 100644 --- a/MinecraftClient/Protocol/Session/SessionToken.cs +++ b/MinecraftClient/Protocol/Session/SessionToken.cs @@ -54,6 +54,16 @@ namespace MinecraftClient.Protocol.Session return false; } + public async Task SessionPreCheckAsync(LoginType type) + { + if (ID == string.Empty || PlayerID == String.Empty || ServerPublicKey is null) + return false; + + Crypto.CryptoHandler.ClientAESPrivateKey ??= Crypto.CryptoHandler.GenerateAESPrivateKey(); + string serverHash = Crypto.CryptoHandler.GetServerHash(ServerIDhash, ServerPublicKey, Crypto.CryptoHandler.ClientAESPrivateKey); + return await ProtocolHandler.SessionCheckAsync(PlayerID, ID, serverHash, type); + } + public override string ToString() { return String.Join(",", ID, PlayerName, PlayerID, ClientID, RefreshToken, ServerIDhash, diff --git a/MinecraftClient/TaskWithResult.cs b/MinecraftClient/TaskWithResult.cs index 53aec3aa..23b0dbc1 100644 --- a/MinecraftClient/TaskWithResult.cs +++ b/MinecraftClient/TaskWithResult.cs @@ -1,20 +1,24 @@ using System; using System.Threading; +using System.Threading.Tasks; namespace MinecraftClient { + internal interface IMainThreadTask + { + void ExecuteSynchronously(); + void Cancel(); + } + /// /// Holds an asynchronous task with return value /// /// Type of the return value - public class TaskWithResult + public sealed class TaskWithResult : IMainThreadTask { - private readonly AutoResetEvent resultEvent = new(false); private readonly Func task; - private T? result = default; - private Exception? exception = null; - private bool taskRun = false; - private readonly Lock taskRunLock = new(); + private readonly TaskCompletionSource completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int taskState; /// /// Create a new asynchronous task with return value @@ -28,13 +32,7 @@ namespace MinecraftClient /// /// Check whether the task has finished running /// - public bool HasRun - { - get - { - return taskRun; - } - } + public bool HasRun => completionSource.Task.IsCompleted; /// /// Get the task result (return value of the inner delegate) @@ -44,10 +42,10 @@ namespace MinecraftClient { get { - if (taskRun) - return result!; - else + if (!completionSource.Task.IsCompleted) throw new InvalidOperationException("Attempting to retrieve the result of an unfinished task"); + + return completionSource.Task.GetAwaiter().GetResult(); } } @@ -58,40 +56,39 @@ namespace MinecraftClient { get { - return exception; + return completionSource.Task.Exception?.InnerException; } } + public Task AsTask() + { + return completionSource.Task; + } + /// /// Execute the task in the current thread and set the property or to the returned value /// public void ExecuteSynchronously() { - // Make sur the task will not run twice - lock (taskRunLock) - { - if (taskRun) - { - throw new InvalidOperationException("Attempting to run a task twice"); - } - } + if (Interlocked.CompareExchange(ref taskState, 1, 0) != 0) + throw new InvalidOperationException("Attempting to run a task twice"); - // Run the task try { - result = task(); + completionSource.TrySetResult(task()); } catch (Exception e) { - exception = e; + completionSource.TrySetException(e); } + } - // Mark task as complete and release wait event - lock (taskRunLock) - { - taskRun = true; - } - resultEvent.Set(); + public void Cancel() + { + if (Interlocked.CompareExchange(ref taskState, 1, 0) != 0) + return; + + completionSource.TrySetException(new OperationCanceledException("Main-thread task was canceled before execution.")); } /// @@ -101,22 +98,7 @@ namespace MinecraftClient /// Any exception thrown by the task public T WaitGetResult() { - // Wait only if the result is not available yet - bool mustWait = false; - lock (taskRunLock) - { - mustWait = !taskRun; - } - if (mustWait) - { - resultEvent.WaitOne(); - } - - // Receive exception from task - if (exception is not null) - throw exception; - - return result!; + return completionSource.Task.GetAwaiter().GetResult(); } } }