Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c680d7c02
|
||
|
|
8763ff3206 | ||
|
|
2038be4355
|
||
|
|
aff40e8857 | ||
|
|
6e69f9619d | ||
|
|
1105a4d076 | ||
|
|
fb83501566 | ||
|
|
42220c0e06 | ||
|
|
135256c9f1 | ||
|
|
4c7406723c | ||
|
|
3275d197e6 | ||
|
|
2e940d078a
|
No files matched your search
@@ -0,0 +1,50 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
windows:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
commands: |
|
||||
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
|
||||
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror --no-restore
|
||||
# The WinUI app project is built directly so the format workspace can load its generated XAML code.
|
||||
dotnet build src/MarkdownViewr.Windows/MarkdownViewr.Windows.csproj -p:Platform=x64 -warnaserror --no-restore
|
||||
$env:Platform = 'x64'
|
||||
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
|
||||
Remove-Item Env:Platform
|
||||
dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --logger trx --results-directory TestResults
|
||||
pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core
|
||||
# PowerShell build scripts: analyzer and Pester boundary tests with pinned modules saved to the job's temp directory.
|
||||
$modules = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "psmodules-$env:GITHUB_RUN_ID"
|
||||
New-Item -ItemType Directory -Force -Path $modules | Out-Null
|
||||
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Import-Module (Join-Path $modules PSScriptAnalyzer/1.25.0/PSScriptAnalyzer.psd1)
|
||||
Import-Module (Join-Path $modules Pester/5.7.1/Pester.psd1)
|
||||
foreach ($file in Get-ChildItem -LiteralPath build -Recurse -File | Where-Object Extension -In '.ps1', '.psm1', '.psd1') {
|
||||
# Casing runs in its own pass: command metadata races other rules in 1.25.0 (PSScriptAnalyzer#1708).
|
||||
$findings = @(
|
||||
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -IncludeRule PSUseCorrectCasing
|
||||
Invoke-ScriptAnalyzer -Path $file.FullName -Settings ./PSScriptAnalyzerSettings.psd1 -ExcludeRule PSUseCorrectCasing
|
||||
)
|
||||
if ($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $($file.Name)" }
|
||||
}
|
||||
# The Pester suites assert on child processes' exit codes themselves.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$pester = New-PesterConfiguration
|
||||
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/GitHubRepository.Tests.ps1', 'build/Distribution.Tests.ps1', 'build/InstalledUpdates.Tests.ps1', 'build/PackageBytes.Tests.ps1', 'build/ReleaseSigning.Tests.ps1')
|
||||
$pester.Run.PassThru = $true
|
||||
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
|
||||
scripts:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '24'
|
||||
install: npm ci --ignore-scripts
|
||||
commands: |
|
||||
npm run lint
|
||||
npm test
|
||||
npm audit signatures
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: >-
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build
|
||||
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
|
||||
--filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~Outline_NestedCollapse'
|
||||
--logger 'trx;LogFileName=reader-regressions.trx'
|
||||
--results-directory artifacts/reader-regressions
|
||||
- name: Retain native regression evidence
|
||||
|
||||
@@ -41,21 +41,12 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet build tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 -warnaserror --no-restore
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Restore and build activation adapter targets
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet restore tests/MarkdownViewr.Windows.UiTests -p:Platform=x64 --locked-mode
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet build tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 -warnaserror --no-restore
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Verify seeded parser security properties
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Output "Fuzz seeds22023/99173, five targets, $env:K22_FUZZ_ITERATIONS iterations per seed/target."
|
||||
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=fuzz.trx' --results-directory artifacts/fuzz
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=activation-fuzz.trx' --results-directory artifacts/fuzz
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Retain synthetic campaign failures and results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
|
||||
@@ -33,10 +33,22 @@ crossing processes; the public SDK launch envelope cannot carry sender CWD.
|
||||
Deliver requests through a current-user-only .NET named pipe. Accept only a
|
||||
source-generated bounded schema: at most128 absolute paths, a1MiB frame and an
|
||||
optional fixed invalid-path notification. Reject unknown fields; no command or
|
||||
script operation exists. Connection/read/acknowledgement waits are bounded to
|
||||
10seconds, with at most16 requests queued before XAML attaches its dispatcher.
|
||||
Malformed/disconnected peers cannot stop later valid requests. App shutdown
|
||||
cancels and disposes the listener. No dependencies or generic RPC layer are added.
|
||||
script operation exists. A sender waits at most2 seconds for the reader's pipe
|
||||
and each side at most10 seconds for one exchange, with at most16 requests held
|
||||
before XAML attaches its dispatcher. Malformed/disconnected peers cannot stop
|
||||
later valid requests. App shutdown cancels and disposes the listener, releasing
|
||||
the pipe name before the instance key. No dependencies or generic RPC layer are added.
|
||||
|
||||
The request, the schema and the inbox live in the core library
|
||||
(`Core/Activation`), where the unit tests and the coverage gate reach them.
|
||||
|
||||
Forwarding is best effort; opening the document is not. A launch whose request
|
||||
the running reader did not take (no listener, a refusal, or a deadline) opens
|
||||
its documents in its own process. A reader that cannot create its inbox, because
|
||||
the pipe name is already held by a reader that is still closing or by another
|
||||
program, runs without one; later launches then open their own documents. An
|
||||
argument that cannot name a document is dropped and reported in the reader
|
||||
window while the usable arguments still open.
|
||||
|
||||
The foreground launcher permits the primary to focus an existing document.
|
||||
Dispatch all window creation on the native UI thread; duplicate paths include
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"reason": "Untrusted Markdown/HTML/frontmatter, source ranges/folds, resource URLs, imported theme JSON and activation JSON are parser boundaries. Seeded mutation targets verify unsafe acceptance as well as exceptions.",
|
||||
"targets": [
|
||||
"tests/MarkdownViewr.Windows.Core.Tests/SecurityFuzzTests.cs",
|
||||
"tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs"
|
||||
"tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs"
|
||||
],
|
||||
"command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz",
|
||||
"workflow": ".github/workflows/fuzz.yml",
|
||||
|
||||
@@ -30,8 +30,7 @@ Run from the repository root with the pinned .NET 10 SDK on Windows:
|
||||
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
|
||||
$env:K22_FUZZ_ITERATIONS = '500'
|
||||
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --filter Category=Fuzz
|
||||
dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --filter Category=Fuzz
|
||||
# Weekly and before every MINOR/MAJOR release, repeat both commands with 10000.
|
||||
# Weekly and before every MINOR/MAJOR release, repeat the command with 10000.
|
||||
$env:K22_FUZZ_ITERATIONS = '10000'
|
||||
```
|
||||
|
||||
|
||||
@@ -324,6 +324,13 @@ relative/control-containing/null/overlong paths and more than128 paths; frames
|
||||
are bounded to1MiB, connection/read waits to10seconds and pre-XAML queuing to16
|
||||
requests. Normal document size/resource/sanitizer boundaries still apply.
|
||||
|
||||
The pipe name is derived from the executable and data folders, so another
|
||||
program running as any user on the machine can create that name first. That
|
||||
cannot expose a document or redirect one: both ends require a pipe owned by the
|
||||
current user. It could only deny forwarding, so a reader that cannot create its
|
||||
inbox starts without one, and a launch whose request is not taken opens its own
|
||||
documents.
|
||||
|
||||
Malformed/disconnected requests are rejected and the listener continues; owned
|
||||
shutdown cancels the pipe. Fixed notifications and failure kind/HRESULT logging
|
||||
do not expose paths or payloads. UI-dispatched duplicate activation preserves
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"version": "10.0.403",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
/// <summary>
|
||||
/// The running reader's inbox for documents a later launch asks it to open. One named pipe,
|
||||
/// open only to the current Windows user, carries one length-prefixed <see cref="ActivationRequest"/>
|
||||
/// per connection and answers with one byte: accepted or not.
|
||||
/// </summary>
|
||||
public sealed class ActivationInbox : IDisposable
|
||||
{
|
||||
/// <summary>Requests held while the reader is still starting; a further one is refused so the sender can report it.</summary>
|
||||
public const int MaximumPendingRequests = 16;
|
||||
|
||||
/// <summary>How long a sender waits for the reader's pipe to exist and be free.</summary>
|
||||
public static readonly TimeSpan ConnectDeadline = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>How long either side waits for the other to finish one request.</summary>
|
||||
public static readonly TimeSpan ExchangeDeadline = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<ActivationRequest> _pending = new();
|
||||
private readonly CancellationTokenSource _closed = new();
|
||||
private readonly NamedPipeServerStream _pipe;
|
||||
private readonly Action<Exception> _report;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Task _listener;
|
||||
private Func<ActivationRequest, bool>? _dispatch;
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates the pipe and starts listening. Throws when the pipe name is already taken, by a
|
||||
/// reader that has not finished closing or by another program; the caller then runs without
|
||||
/// an inbox. Deadlines run on <paramref name="time"/>, the system clock unless a test supplies its own.
|
||||
/// </summary>
|
||||
public ActivationInbox(string key, Action<Exception> report, TimeProvider? time = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
_report = report;
|
||||
_time = time ?? TimeProvider.System;
|
||||
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
_listener = ListenAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Completes when the listener has stopped after <see cref="Dispose"/>.</summary>
|
||||
public Task Completion => _listener;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Connects the inbox to the reader once its windows can open documents, delivering first the
|
||||
/// requests that arrived while it was starting. The dispatcher returns false when it cannot
|
||||
/// take a request.
|
||||
/// </summary>
|
||||
public void Attach(Func<ActivationRequest, bool> dispatch)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatch);
|
||||
lock(_gate)
|
||||
{
|
||||
_dispatch = dispatch;
|
||||
while(_pending.TryDequeue(out ActivationRequest? request))
|
||||
{
|
||||
if(!dispatch(request))
|
||||
{
|
||||
_report(new IOException("The reader could not accept activation."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one request to the running reader and returns whether it accepted it.</summary>
|
||||
public static Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
|
||||
=> SendAsync(key, request, TimeProvider.System, cancellationToken);
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one request with its deadlines on the given clock. Throws when no reader answers in time or the request is too large to send.</summary>
|
||||
internal static async Task<bool> SendAsync(string key, ActivationRequest request, TimeProvider time, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
request.Validate();
|
||||
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
|
||||
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new ArgumentException("The activation payload is too large.", nameof(request));
|
||||
}
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
using(var deadline = new CancellationTokenSource(ConnectDeadline, time))
|
||||
using(var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token))
|
||||
{
|
||||
await client.ConnectAsync(connecting.Token);
|
||||
}
|
||||
using var exchangeDeadline = new CancellationTokenSource(ExchangeDeadline, time);
|
||||
using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exchangeDeadline.Token);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
|
||||
await client.WriteAsync(length, exchanging.Token);
|
||||
await client.WriteAsync(payload, exchanging.Token);
|
||||
await client.FlushAsync(exchanging.Token);
|
||||
byte[] response = new byte[1];
|
||||
await client.ReadExactlyAsync(response, exchanging.Token);
|
||||
return response[0] == 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Serves one connection at a time until disposed; a peer that disconnects, stalls or sends an invalid request is reported and does not stop the next one.</summary>
|
||||
private async Task ListenAsync()
|
||||
{
|
||||
while(!_closed.IsCancellationRequested)
|
||||
{
|
||||
bool connected = false;
|
||||
try
|
||||
{
|
||||
await _pipe.WaitForConnectionAsync(_closed.Token);
|
||||
connected = true;
|
||||
using var deadline = new CancellationTokenSource(ExchangeDeadline, _time);
|
||||
using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token, deadline.Token);
|
||||
bool accepted = Accept(await ReadRequestAsync(exchanging.Token));
|
||||
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, exchanging.Token);
|
||||
await _pipe.FlushAsync(exchanging.Token);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
if(!_closed.IsCancellationRequested)
|
||||
{
|
||||
_report(exception);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Under the lock Dispose takes, so the pipe cannot be disposed between the check and the call.
|
||||
lock(_gate)
|
||||
{
|
||||
if(connected && !_closed.IsCancellationRequested)
|
||||
{
|
||||
// A failed read clears IsConnected, but the connection still has to be released before the next peer.
|
||||
_pipe.Disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads one bounded, length-prefixed request and checks it against the schema and the path rules.</summary>
|
||||
private async Task<ActivationRequest> ReadRequestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] length = new byte[4];
|
||||
await _pipe.ReadExactlyAsync(length, cancellationToken);
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
|
||||
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new InvalidDataException("Invalid activation length.");
|
||||
}
|
||||
byte[] payload = new byte[size];
|
||||
await _pipe.ReadExactlyAsync(payload, cancellationToken);
|
||||
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
|
||||
request.Validate();
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hands the request to the reader, or holds it until the reader attaches; false when neither is possible.</summary>
|
||||
private bool Accept(ActivationRequest request)
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
if(_dispatch is not null)
|
||||
{
|
||||
return _dispatch(request);
|
||||
}
|
||||
if(_pending.Count >= MaximumPendingRequests)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pending.Enqueue(request);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Stops listening and releases the pipe name.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if(Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock(_gate)
|
||||
{
|
||||
_closed.Cancel();
|
||||
_pipe.Dispose();
|
||||
}
|
||||
_closed.Dispose();
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -6,10 +6,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
/// <summary>Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol.</summary>
|
||||
/// <summary>The fixed schema of an activation message; a field it does not define is an error, so the message cannot grow into a command protocol.</summary>
|
||||
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
|
||||
[JsonSerializable(typeof(ActivationRequest))]
|
||||
internal sealed partial class ActivationJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
/// <summary>
|
||||
/// The documents one launch asks to open: bounded, literal, absolute paths. This is the whole
|
||||
/// message a second launch sends to the running reader; it carries no commands.
|
||||
/// </summary>
|
||||
public sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
|
||||
{
|
||||
/// <summary>The most documents one launch may open.</summary>
|
||||
public const int MaximumPaths = 128;
|
||||
|
||||
/// <summary>The largest serialized request the running reader accepts.</summary>
|
||||
public const int MaximumPayloadBytes = 1024 * 1024;
|
||||
|
||||
private const int MaximumPathCharacters = 32767;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves each argument against the launching process's folder, so a relative path keeps
|
||||
/// its meaning when the running reader has a different working folder. An argument that is
|
||||
/// not a usable path is dropped and flagged; the usable ones still open.
|
||||
/// </summary>
|
||||
public static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
|
||||
if(candidates.Length > MaximumPaths)
|
||||
{
|
||||
throw new ArgumentException("Too many document paths.", nameof(paths));
|
||||
}
|
||||
string?[] resolved = candidates.Select(path => Resolve(path, workingDirectory)).ToArray();
|
||||
return new ActivationRequest(resolved.OfType<string>().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), resolved.Contains(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rejects a request that did not come from <see cref="Create"/>: a relative, empty, overlong or control-bearing path, or too many of them.</summary>
|
||||
public void Validate()
|
||||
{
|
||||
if(Paths is null || Paths.Length > MaximumPaths || !Paths.All(IsUsable))
|
||||
{
|
||||
throw new InvalidDataException("Invalid document activation paths.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Names the single-instance group: one per executable folder and data folder, so stable and
|
||||
/// development editions, loose builds and test profiles never forward to each other.
|
||||
/// </summary>
|
||||
public static string InstanceKey(string executableDirectory, string dataDirectory)
|
||||
{
|
||||
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
|
||||
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The absolute form of one argument, or null when it cannot name a document.</summary>
|
||||
private static string? Resolve(string? path, string workingDirectory)
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
string full = Path.GetFullPath(path, workingDirectory);
|
||||
return IsUsable(full) ? full : null;
|
||||
}
|
||||
catch(Exception exception) when(exception is ArgumentException or NotSupportedException or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A path the reader may be asked to open: absolute, bounded, and free of control characters.</summary>
|
||||
private static bool IsUsable(string? path)
|
||||
=> !string.IsNullOrEmpty(path) && path.Length <= MaximumPathCharacters && Path.IsPathFullyQualified(path) && !path.Any(char.IsControl);
|
||||
}
|
||||
@@ -32,9 +32,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.12, )",
|
||||
"resolved": "10.0.12",
|
||||
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
|
||||
"requested": "[10.0.14, )",
|
||||
"resolved": "10.0.14",
|
||||
"contentHash": "YlTamXppEQls6DYWt7RdUuwWDb0lyw/vXCaGeQpLCpsLmfwGuCkOz/vIGgMTV7ECsJcavkCOkxZXv9s8rdAHkQ=="
|
||||
},
|
||||
"AngleSharp": {
|
||||
"type": "Transitive",
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Bounded current-user IPC delivers sender-canonicalized document paths to the SDK's chosen primary instance.</summary>
|
||||
internal sealed class ActivationInbox : IDisposable
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<ActivationRequest> _pending = new();
|
||||
private readonly CancellationTokenSource _closed = new();
|
||||
private readonly NamedPipeServerStream _pipe;
|
||||
private readonly Action<Exception> _report;
|
||||
private readonly Task _listener;
|
||||
private Func<ActivationRequest, bool>? _dispatch;
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>The primary owns the pipe before XAML starts; only the same Windows user can connect.</summary>
|
||||
internal ActivationInbox(string key, Action<Exception> report)
|
||||
{
|
||||
_report = report;
|
||||
_pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
_listener = ListenAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The owned listener completes on disposal; tests and shutdown diagnostics can observe failures instead of losing a task.</summary>
|
||||
internal Task Completion => _listener;
|
||||
|
||||
|
||||
|
||||
/// <summary>Attaches the native UI dispatcher after launch, delivering bounded requests received while XAML initialized.</summary>
|
||||
internal void Attach(Func<ActivationRequest, bool> dispatch)
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
_dispatch = dispatch;
|
||||
while(_pending.TryDequeue(out ActivationRequest? request))
|
||||
{
|
||||
if(!dispatch(request))
|
||||
{
|
||||
_report(new IOException("The reader could not accept activation."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one fixed request with a bounded connection/acknowledgement wait and preserves the real server acknowledgement.</summary>
|
||||
internal static async Task<bool> SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.Validate();
|
||||
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
|
||||
if(payload.Length > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new ArgumentException("The activation payload is too large.", nameof(request));
|
||||
}
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await client.ConnectAsync(timeout.Token);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
|
||||
await client.WriteAsync(length, timeout.Token);
|
||||
await client.WriteAsync(payload, timeout.Token);
|
||||
await client.FlushAsync(timeout.Token);
|
||||
byte[] response = new byte[1];
|
||||
await client.ReadExactlyAsync(response, timeout.Token);
|
||||
return response[0] == 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A disconnected or malformed peer cannot stop later activations; each request has a bounded read deadline.</summary>
|
||||
private async Task ListenAsync()
|
||||
{
|
||||
while(!_closed.IsCancellationRequested)
|
||||
{
|
||||
bool connected = false;
|
||||
try
|
||||
{
|
||||
await _pipe.WaitForConnectionAsync(_closed.Token);
|
||||
connected = true;
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
byte[] length = new byte[4];
|
||||
await _pipe.ReadExactlyAsync(length, timeout.Token);
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(length);
|
||||
if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
|
||||
{
|
||||
throw new InvalidDataException("Invalid activation length.");
|
||||
}
|
||||
byte[] payload = new byte[size];
|
||||
await _pipe.ReadExactlyAsync(payload, timeout.Token);
|
||||
ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
|
||||
request.Validate();
|
||||
bool accepted;
|
||||
lock(_gate)
|
||||
{
|
||||
accepted = _dispatch is null ? QueueBeforeLaunch(request) : _dispatch(request);
|
||||
}
|
||||
await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, timeout.Token);
|
||||
await _pipe.FlushAsync(timeout.Token);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or ArgumentException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
if(!_closed.IsCancellationRequested)
|
||||
{
|
||||
_report(exception);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(connected && !_closed.IsCancellationRequested)
|
||||
{
|
||||
// A broken read clears IsConnected, but the owned native connection still requires Disconnect.
|
||||
try
|
||||
{
|
||||
_pipe.Disconnect();
|
||||
}
|
||||
catch(ObjectDisposedException) when(_closed.IsCancellationRequested)
|
||||
{
|
||||
// Shutdown may release the handle after the cancellation check.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A startup burst can queue at most16 requests; the peer is told to retry if the native reader is busy.</summary>
|
||||
private bool QueueBeforeLaunch(ActivationRequest request)
|
||||
{
|
||||
if(_pending.Count >= 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pending.Enqueue(request);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Application shutdown cancels pending connections and releases the owned current-user pipe.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if(Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_closed.Cancel();
|
||||
_pipe.Dispose();
|
||||
_closed.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
/// <summary>Only bounded literal absolute document paths cross the current-user activation pipe; no native commands are accepted.</summary>
|
||||
internal sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
|
||||
{
|
||||
internal const int MaximumPaths = 128;
|
||||
internal const int MaximumPayloadBytes = 1024 * 1024;
|
||||
|
||||
|
||||
|
||||
/// <summary>Canonicalizes at the sender, preserving relative-path meaning even when the existing reader has a different working directory.</summary>
|
||||
internal static ActivationRequest Create(IEnumerable<string> paths, string workingDirectory)
|
||||
{
|
||||
string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
|
||||
if(candidates.Length > MaximumPaths)
|
||||
{
|
||||
throw new ArgumentException("Too many document paths.", nameof(paths));
|
||||
}
|
||||
var request = new ActivationRequest(candidates.Select(path => Path.GetFullPath(path, workingDirectory)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
request.Validate();
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Malformed, oversized or relative pipe input cannot become an unbounded launch or be interpreted using the receiver's directory.</summary>
|
||||
internal void Validate()
|
||||
{
|
||||
if(Paths is null || Paths.Length > MaximumPaths || Paths.Any(path => string.IsNullOrEmpty(path) || path.Length > 32767 || !Path.IsPathFullyQualified(path) || path.Any(char.IsControl)))
|
||||
{
|
||||
throw new InvalidDataException("Invalid document activation paths.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Profile and executable directories define distinct stable/dev, development-build and review/test instance identities.</summary>
|
||||
internal static string InstanceKey(string executableDirectory, string dataDirectory)
|
||||
{
|
||||
string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
|
||||
return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
using Microsoft.Windows.AppLifecycle;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.Windows.AppLifecycle;
|
||||
using Velopack;
|
||||
@@ -16,17 +17,21 @@ namespace MarkdownViewr.Windows;
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>The primary's bounded current-user inbox is attached by App only after the native reader initializes.</summary>
|
||||
/// <summary>The running reader's inbox; null in a process that could not create one or that opened its documents itself.</summary>
|
||||
internal static ActivationInbox? Inbox { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>The initial canonical request preserves sender-relative paths and SDK rich file activation.</summary>
|
||||
/// <summary>The documents this launch was asked to open, resolved against its own working folder.</summary>
|
||||
internal static ActivationRequest InitialRequest { get; private set; } = new([]);
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request.</summary>
|
||||
/// <summary>
|
||||
/// Runs the installer hooks, then either hands this launch's documents to the reader that is
|
||||
/// already running or becomes a reader. A launch whose documents the running reader could not
|
||||
/// take opens them itself, so a double-clicked file always opens.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
private static int Main()
|
||||
{
|
||||
@@ -39,12 +44,12 @@ public static class Program
|
||||
WinRT.ComWrappersSupport.InitializeComWrappers();
|
||||
InitialRequest = DesktopActivation.ReadInitialRequest();
|
||||
string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!);
|
||||
var primary = AppInstance.FindOrRegisterForKey(key);
|
||||
if(!primary.IsCurrent)
|
||||
var instance = AppInstance.FindOrRegisterForKey(key);
|
||||
if(!instance.IsCurrent && Forward(key, instance.ProcessId))
|
||||
{
|
||||
return ForwardToPrimary(key, primary.ProcessId);
|
||||
return 0;
|
||||
}
|
||||
using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure);
|
||||
ActivationInbox? inbox = instance.IsCurrent ? OpenInbox(key) : null;
|
||||
Inbox = inbox;
|
||||
try
|
||||
{
|
||||
@@ -57,30 +62,54 @@ public static class Program
|
||||
finally
|
||||
{
|
||||
Inbox = null;
|
||||
primary.UnregisterKey();
|
||||
// The pipe name is released before the instance key, so the next reader to register can create its own inbox.
|
||||
inbox?.Dispose();
|
||||
if(instance.IsCurrent)
|
||||
{
|
||||
instance.UnregisterKey();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Current-user .NET pipe delivery avoids the SDK STA redirection deadlock and resolves relative paths before crossing processes.</summary>
|
||||
private static int ForwardToPrimary(string key, uint processId)
|
||||
/// <summary>
|
||||
/// Creates the inbox, or returns null when the pipe name is taken. The reader then runs
|
||||
/// without one: later launches find nobody listening and open their documents themselves.
|
||||
/// </summary>
|
||||
private static ActivationInbox? OpenInbox(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new ActivationInbox(key, DesktopActivation.ReportFailure);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
DesktopActivation.ReportFailure(exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Hands this launch's documents to the running reader; false when it did not take them.</summary>
|
||||
private static bool Forward(string key, uint processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
DesktopActivation.AllowPrimaryForeground(processId);
|
||||
bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if(!delivered)
|
||||
// A plain pipe is used because the SDK's own redirection deadlocks an STA entry point.
|
||||
if(ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult())
|
||||
{
|
||||
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
|
||||
return true;
|
||||
}
|
||||
return delivered ? 0 : 1;
|
||||
DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException)
|
||||
{
|
||||
DesktopActivation.ReportFailure(exception);
|
||||
return 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -6,9 +6,10 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Fuzzes the real generated activation schema and path validator without creating a window, pipe, process or profile.</summary>
|
||||
[Trait("Category", "Fuzz")]
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Real current-user pipes between a real inbox and real senders. Deadlines run on a manual
|
||||
/// clock, so no test waits for one and none can be reached early by a slow machine.
|
||||
/// </summary>
|
||||
public sealed class ActivationInboxTests
|
||||
{
|
||||
private static readonly TimeSpan s_safetyNet = TimeSpan.FromSeconds(30);
|
||||
|
||||
|
||||
|
||||
/// <summary>A request sent while the reader is starting is held until it attaches; afterwards each request gets the reader's own answer.</summary>
|
||||
[Fact]
|
||||
public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Empty(received);
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
inbox.Attach(_ => false);
|
||||
Assert.False(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>While the reader is starting it holds a bounded number of requests and refuses the next, which the sender is told.</summary>
|
||||
[Fact]
|
||||
public async Task Send_MoreThanTheBoundBeforeTheReaderAttaches_RefusesTheExtraRequest()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
using var inbox = new ActivationInbox(key, _ => { });
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
for(int index = 0; index < ActivationInbox.MaximumPendingRequests; index++)
|
||||
{
|
||||
Assert.True(await ActivationInbox.SendAsync(key, ActivationRequest.Create([$"held{index}.md"], Path.GetTempPath()), safety.Token));
|
||||
}
|
||||
Assert.False(await ActivationInbox.SendAsync(key, ActivationRequest.Create(["one too many.md"], Path.GetTempPath()), safety.Token));
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
Assert.Equal(Enumerable.Range(0, ActivationInbox.MaximumPendingRequests).Select(index => $"held{index}.md"), received.Select(request => Path.GetFileName(Assert.Single(request.Paths))), StringComparer.Ordinal);
|
||||
Assert.Equal(16, ActivationInbox.MaximumPendingRequests);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A held request the reader then cannot take is reported, not silently dropped.</summary>
|
||||
[Fact]
|
||||
public async Task Attach_HeldRequestTheReaderRefuses_IsReported()
|
||||
{
|
||||
string key = NewKey();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
inbox.Attach(_ => false);
|
||||
Assert.IsType<IOException>(Assert.Single(errors));
|
||||
Assert.Throws<ArgumentNullException>(() => inbox.Attach(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new ActivationInbox(NewKey(), null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The pipe name belongs to one inbox at a time. A second one fails to start, so its process
|
||||
/// can run without an inbox, and the name is free again once the first is disposed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Constructor_NameAlreadyTaken_ThrowsUntilTheOwnerIsDisposed()
|
||||
{
|
||||
string key = NewKey();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Task stopped;
|
||||
using(var owner = new ActivationInbox(key, _ => { }))
|
||||
{
|
||||
Assert.ThrowsAny<IOException>(() => new ActivationInbox(key, _ => { }));
|
||||
stopped = owner.Completion;
|
||||
}
|
||||
await stopped.WaitAsync(safety.Token);
|
||||
using var successor = new ActivationInbox(key, _ => { });
|
||||
successor.Attach(_ => true);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A frame with a zero, negative or oversized length, an unknown field, a relative path or a null body is rejected; the next valid sender is still served.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_MalformedPeers_RejectsAndRecovers()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await SendFrame(key, ActivationRequest.MaximumPayloadBytes + 1, [], safety.Token);
|
||||
await SendFrame(key, 0, [], safety.Token);
|
||||
await SendFrame(key, -1, [], safety.Token);
|
||||
foreach(string body in new[] { "{\"Paths\":[],\"Command\":\"execute\"}", "{\"Paths\":[\"relative.md\"]}", "null" })
|
||||
{
|
||||
byte[] payload = Encoding.UTF8.GetBytes(body);
|
||||
await SendFrame(key, payload.Length, payload, safety.Token);
|
||||
}
|
||||
var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
// Each frame is refused for its own reason: the three lengths before any payload is read, then the schema, the path rule and the empty body.
|
||||
Type[] expected = [typeof(InvalidDataException), typeof(InvalidDataException), typeof(InvalidDataException), typeof(System.Text.Json.JsonException), typeof(InvalidDataException), typeof(InvalidDataException)];
|
||||
Assert.Equal(expected, errors.Select(error => error.GetType()));
|
||||
Assert.Equal(1024 * 1024, ActivationRequest.MaximumPayloadBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A frame of exactly the largest size is read and accepted.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_FrameOfExactlyTheLargestSize_IsAccepted()
|
||||
{
|
||||
string key = NewKey();
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
byte[] payload = new byte[ActivationRequest.MaximumPayloadBytes];
|
||||
Array.Fill(payload, (byte)' ');
|
||||
"{\"Paths\":[\"C:\\\\largest.md\"]}"u8.CopyTo(payload);
|
||||
Assert.Equal(1, await SendFrame(key, payload.Length, payload, safety.Token));
|
||||
Assert.Equal([@"C:\largest.md"], Assert.Single(received).Paths);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sender never puts an invalid or oversized request on the pipe.</summary>
|
||||
[Fact]
|
||||
public async Task Send_InvalidOrOversizedRequest_IsRefusedBeforeConnecting()
|
||||
{
|
||||
string key = NewKey();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => ActivationInbox.SendAsync(key, new ActivationRequest(["relative.md"]), safety.Token));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => ActivationInbox.SendAsync(key, null!, safety.Token));
|
||||
string longPath = @"C:\" + new string('a', 9000);
|
||||
var oversized = new ActivationRequest(Enumerable.Range(0, ActivationRequest.MaximumPaths).Select(index => longPath + index.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray());
|
||||
oversized.Validate();
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => ActivationInbox.SendAsync(key, oversized, safety.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>With no reader listening, a sender gives up at its connect deadline instead of waiting forever.</summary>
|
||||
[Fact]
|
||||
public async Task Send_NoReaderListening_GivesUpAtTheConnectDeadline()
|
||||
{
|
||||
var clock = new ManualTimeProvider();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Task<bool> sending = ActivationInbox.SendAsync(NewKey(), new ActivationRequest([]), clock, safety.Token);
|
||||
await clock.NextTimerAsync(safety.Token);
|
||||
Assert.False(sending.IsCompleted);
|
||||
clock.Elapse();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sending);
|
||||
Assert.False(safety.IsCancellationRequested);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), ActivationInbox.ConnectDeadline);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A peer that connects and sends nothing is dropped at the exchange deadline, and the next sender is served.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_PeerThatSendsNothing_IsDroppedAtTheDeadline()
|
||||
{
|
||||
string key = NewKey();
|
||||
var clock = new ManualTimeProvider();
|
||||
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception), clock);
|
||||
inbox.Attach(_ => true);
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await using(var silent = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await silent.ConnectAsync(safety.Token);
|
||||
await clock.NextTimerAsync(safety.Token);
|
||||
Assert.False(reported.Task.IsCompleted);
|
||||
clock.Elapse();
|
||||
Assert.IsAssignableFrom<OperationCanceledException>(await reported.Task.WaitAsync(safety.Token));
|
||||
}
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), ActivationInbox.ExchangeDeadline);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A sender that disconnects without a request is reported and does not block the next one; disposing twice is harmless and frees the name.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
|
||||
{
|
||||
string key = NewKey();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
Task completed;
|
||||
using(var inbox = new ActivationInbox(key, errors.Enqueue))
|
||||
{
|
||||
inbox.Attach(_ => true);
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(safety.Token);
|
||||
}
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
Assert.Single(errors);
|
||||
completed = inbox.Completion;
|
||||
inbox.Dispose();
|
||||
inbox.Dispose();
|
||||
}
|
||||
await completed.WaitAsync(safety.Token);
|
||||
Assert.Single(errors);
|
||||
using var replacement = new ActivationInbox(key, _ => { });
|
||||
replacement.Attach(_ => true);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A peer that has already closed when the reader turns to it is released before the next sender connects.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
|
||||
{
|
||||
string key = NewKey();
|
||||
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var connectionContext = new DeferredConnectionContext();
|
||||
SynchronizationContext? previous = SynchronizationContext.Current;
|
||||
ActivationInbox inbox;
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(connectionContext);
|
||||
inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(previous);
|
||||
}
|
||||
using ActivationInbox ownedInbox = inbox;
|
||||
inbox.Attach(value =>
|
||||
{
|
||||
received.Enqueue(value);
|
||||
return true;
|
||||
});
|
||||
using var safety = new CancellationTokenSource(s_safetyNet);
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(safety.Token);
|
||||
}
|
||||
(SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(safety.Token);
|
||||
// Resume the listener only after the peer has closed, so its first read finds a broken pipe.
|
||||
var resume = Task.Run(() => callback(state), safety.Token);
|
||||
Assert.IsAssignableFrom<IOException>(await reported.Task.WaitAsync(safety.Token));
|
||||
var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
await resume.WaitAsync(safety.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A pipe name no other test or process uses.</summary>
|
||||
private static string NewKey() => "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends one raw frame and returns the reader's answer byte, or -1 when the reader closed the connection without answering.</summary>
|
||||
private static async Task<int> SendFrame(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await client.ConnectAsync(cancellationToken);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
|
||||
byte[] response = new byte[1];
|
||||
try
|
||||
{
|
||||
await client.WriteAsync(length, cancellationToken);
|
||||
await client.WriteAsync(payload, cancellationToken);
|
||||
await client.FlushAsync(cancellationToken);
|
||||
return await client.ReadAsync(response, cancellationToken) == 1 ? response[0] : -1;
|
||||
}
|
||||
catch(IOException)
|
||||
{
|
||||
// Windows may report a broken pipe instead of end-of-stream once the reader has rejected the frame.
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Captures the listener's first continuation so a test decides when it resumes.</summary>
|
||||
private sealed class DeferredConnectionContext : SynchronizationContext
|
||||
{
|
||||
/// <summary>The captured continuation and its state.</summary>
|
||||
internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>Holds the first posted continuation instead of running it.</summary>
|
||||
public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using MarkdownViewr.Windows.Core.Activation;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>What one launch may ask the running reader to open, and which launches share a reader.</summary>
|
||||
public sealed class ActivationRequestTests
|
||||
{
|
||||
/// <summary>Relative arguments resolve against the launching folder; shell characters stay literal and duplicate paths collapse without regard to case.</summary>
|
||||
[Fact]
|
||||
public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "sender space");
|
||||
var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
|
||||
Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
|
||||
Assert.False(request.HasInvalidPath);
|
||||
Assert.Empty(ActivationRequest.Create([], directory).Paths);
|
||||
Assert.False(ActivationRequest.Create([], directory).HasInvalidPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An argument that cannot name a document is dropped and flagged; the documents beside it still open.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("bad\0name.md")]
|
||||
[InlineData("line\nbreak.md")]
|
||||
public void Create_UnusableArgumentBesideUsableOnes_KeepsTheUsableOnesAndFlagsTheRest(string unusable)
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "sender");
|
||||
var request = ActivationRequest.Create(["first.md", unusable, "second.md"], directory);
|
||||
Assert.Equal(new[] { Path.Combine(directory, "first.md"), Path.Combine(directory, "second.md") }, request.Paths);
|
||||
Assert.True(request.HasInvalidPath);
|
||||
request.Validate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A launch may name at most the bounded number of documents; one more is refused outright.</summary>
|
||||
[Fact]
|
||||
public void Create_PathCountAtAndPastTheBound_IsBoundedExactly()
|
||||
{
|
||||
string directory = Path.GetTempPath();
|
||||
IEnumerable<string> names = Enumerable.Range(0, ActivationRequest.MaximumPaths + 1).Select(index => $"document{index}.md");
|
||||
Assert.Equal(ActivationRequest.MaximumPaths, ActivationRequest.Create(names.Take(ActivationRequest.MaximumPaths), directory).Paths.Length);
|
||||
Assert.Throws<ArgumentException>(() => ActivationRequest.Create(names, directory));
|
||||
Assert.Throws<ArgumentNullException>(() => ActivationRequest.Create(null!, directory));
|
||||
Assert.Equal(128, ActivationRequest.MaximumPaths);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A request that did not come from Create is refused: relative, empty, control-bearing, overlong or too many paths.</summary>
|
||||
[Fact]
|
||||
public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(null!).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["relative.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([""]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([null!]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths + 1).ToArray()).Validate());
|
||||
new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths).ToArray()).Validate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A path is accepted up to the Windows limit and refused one character past it.</summary>
|
||||
[Fact]
|
||||
public void Validate_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
|
||||
{
|
||||
string atLimit = @"C:\" + new string('a', 32767 - 3);
|
||||
Assert.Equal(32767, atLimit.Length);
|
||||
new ActivationRequest([atLimit]).Validate();
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([atLimit + "a"]).Validate());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// One reader serves one executable folder and one data folder. Spelling does not matter:
|
||||
/// case and a trailing separator name the same folders. Any other folder is another reader,
|
||||
/// even one whose name has the same length.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "viewer");
|
||||
string data = Path.Combine(Path.GetTempPath(), "profile");
|
||||
string key = ActivationRequest.InstanceKey(root, data);
|
||||
Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
|
||||
Assert.Equal(key, ActivationRequest.InstanceKey(root.ToUpperInvariant(), data + Path.DirectorySeparatorChar));
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, Path.Combine(Path.GetTempPath(), "profilf")), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(data, root), StringComparer.Ordinal);
|
||||
Assert.Matches("^k22-markdownviewr-[0-9A-F]{64}$", key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A clock whose timers fire only when a test says so, so a deadline can be reached without
|
||||
/// waiting for it and can never be reached by a slow machine.
|
||||
/// </summary>
|
||||
internal sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly List<ManualTimer> _timers = [];
|
||||
private TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>Records the timer instead of scheduling it.</summary>
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ManualTimer(callback, state);
|
||||
lock(_gate)
|
||||
{
|
||||
_timers.Add(timer);
|
||||
_created.TrySetResult();
|
||||
}
|
||||
return timer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Completes once code under test has created a timer since the last call.</summary>
|
||||
public Task NextTimerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Task created;
|
||||
lock(_gate)
|
||||
{
|
||||
created = _created.Task;
|
||||
if(created.IsCompleted)
|
||||
{
|
||||
_created = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
return created.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Fires every timer created so far that has not been disposed, as if each deadline had passed.</summary>
|
||||
public void Elapse()
|
||||
{
|
||||
ManualTimer[] pending;
|
||||
lock(_gate)
|
||||
{
|
||||
pending = _timers.ToArray();
|
||||
_timers.Clear();
|
||||
}
|
||||
foreach(ManualTimer timer in pending)
|
||||
{
|
||||
timer.Fire();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A timer that runs its callback once, on request, unless it was disposed first.</summary>
|
||||
private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
|
||||
|
||||
/// <summary>Runs the callback unless the owner has already let go of the timer.</summary>
|
||||
public void Fire()
|
||||
{
|
||||
if(Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
callback(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Rescheduling is accepted and ignored: only <see cref="Elapse"/> fires a timer.</summary>
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks the timer dead so a later <see cref="Elapse"/> skips it.</summary>
|
||||
public void Dispose() => Interlocked.Exchange(ref _disposed, 1);
|
||||
|
||||
|
||||
|
||||
/// <summary>Asynchronous disposal has nothing to wait for.</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <summary>Real current-user pipes prove bounded native delivery without taking desktop input or launching an app.</summary>
|
||||
public sealed class ActivationInboxTests
|
||||
{
|
||||
/// <summary>A startup request survives until a reader dispatcher attaches; rejected dispatch returns a negative acknowledgement.</summary>
|
||||
[Fact]
|
||||
public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Empty(received);
|
||||
inbox.Attach(value => { received.Enqueue(value); return true; });
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
inbox.Attach(_ => false);
|
||||
Assert.False(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(2, received.Count);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Oversized frames, unknown JSON fields and relative pipe paths are rejected, then a real valid peer still connects.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_MalformedPeers_RejectsAndRecovers()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var inbox = new ActivationInbox(key, errors.Enqueue);
|
||||
inbox.Attach(value => { received.Enqueue(value); return true; });
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
await SendMalformed(key, ActivationRequest.MaximumPayloadBytes + 1, [], deadline.Token);
|
||||
byte[] unknown = Encoding.UTF8.GetBytes("{\"Paths\":[],\"Command\":\"execute\"}");
|
||||
await SendMalformed(key, unknown.Length, unknown, deadline.Token);
|
||||
byte[] relative = Encoding.UTF8.GetBytes("{\"Paths\":[\"relative.md\"]}");
|
||||
await SendMalformed(key, relative.Length, relative, deadline.Token);
|
||||
var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
Assert.Equal(3, errors.Count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A disconnected sender never blocks the next activation and shutdown releases the pipe without leaking an owned connection.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var errors = new ConcurrentQueue<Exception>();
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
Task completed;
|
||||
using(var inbox = new ActivationInbox(key, errors.Enqueue))
|
||||
{
|
||||
inbox.Attach(_ => true);
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(deadline.Token);
|
||||
}
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
|
||||
Assert.Single(errors);
|
||||
completed = inbox.Completion;
|
||||
inbox.Dispose();
|
||||
inbox.Dispose();
|
||||
}
|
||||
await completed.WaitAsync(deadline.Token);
|
||||
using var replacement = new ActivationInbox(key, _ => { });
|
||||
replacement.Attach(_ => true);
|
||||
Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>An already-closed peer must release its native connection before another real activation.</summary>
|
||||
[Fact]
|
||||
public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
|
||||
{
|
||||
string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
|
||||
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var received = new ConcurrentQueue<ActivationRequest>();
|
||||
var connectionContext = new DeferredConnectionContext();
|
||||
SynchronizationContext? previous = SynchronizationContext.Current;
|
||||
ActivationInbox inbox;
|
||||
try
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(connectionContext);
|
||||
inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(previous);
|
||||
}
|
||||
using ActivationInbox ownedInbox = inbox;
|
||||
inbox.Attach(value => { received.Enqueue(value); return true; });
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
|
||||
{
|
||||
await client.ConnectAsync(deadline.Token);
|
||||
}
|
||||
(SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(deadline.Token);
|
||||
// Resume the real server after the real peer closes, exercising the synchronous broken-read path.
|
||||
var resume = Task.Run(() => callback(state), deadline.Token);
|
||||
Assert.IsAssignableFrom<IOException>(await reported.Task.WaitAsync(deadline.Token));
|
||||
var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
|
||||
Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
|
||||
Assert.Equal(request.Paths, Assert.Single(received).Paths);
|
||||
await resume.WaitAsync(deadline.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Defers only the first real server continuation, making the already-closed native peer schedule reproducible.</summary>
|
||||
private sealed class DeferredConnectionContext : SynchronizationContext
|
||||
{
|
||||
internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
|
||||
|
||||
/// <summary>Captures the actual asynchronous continuation without changing pipe bytes, handles or acknowledgements.</summary>
|
||||
public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Sends an intentionally invalid frame and waits for the actual server to close it before starting the next peer.</summary>
|
||||
private static async Task SendMalformed(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await client.ConnectAsync(cancellationToken);
|
||||
byte[] length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
|
||||
await client.WriteAsync(length, cancellationToken);
|
||||
await client.WriteAsync(payload, cancellationToken);
|
||||
await client.FlushAsync(cancellationToken);
|
||||
byte[] response = new byte[1];
|
||||
try
|
||||
{
|
||||
Assert.Equal(0, await client.ReadAsync(response, cancellationToken));
|
||||
}
|
||||
catch(IOException)
|
||||
{
|
||||
// Windows may report a broken pipe rather than EOF after the server rejects the malformed frame.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.UiTests;
|
||||
|
||||
/// <summary>Sender-relative paths, Unicode, duplicate launches and stable/dev/profile isolation retain their actual meanings.</summary>
|
||||
public sealed class ActivationRequestTests
|
||||
{
|
||||
/// <summary>Canonicalization belongs to the sender, including literal shell characters and case-insensitive duplicate paths.</summary>
|
||||
[Fact]
|
||||
public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "sender space");
|
||||
var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
|
||||
Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
|
||||
Assert.False(request.HasInvalidPath);
|
||||
Assert.Empty(ActivationRequest.Create([], directory).Paths);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Malformed messages cannot open relative paths or allocate an unbounded number of document windows.</summary>
|
||||
[Fact]
|
||||
public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(null!).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["relative.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest([""]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => new ActivationRequest(["C:\\" + new string('a', 32768)]).Validate());
|
||||
Assert.Throws<ArgumentException>(() => ActivationRequest.Create(Enumerable.Repeat("one.md", ActivationRequest.MaximumPaths + 1), Path.GetTempPath()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Equivalent directory spellings share an instance, while distinct review profiles and channel installation roots do not.</summary>
|
||||
[Fact]
|
||||
public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "viewer");
|
||||
string data = Path.Combine(Path.GetTempPath(), "profile");
|
||||
string key = ActivationRequest.InstanceKey(root, data);
|
||||
Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
|
||||
Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
|
||||
Assert.StartsWith("k22-markdownviewr-", key, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(root, key, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,6 @@
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\DocumentWatcher.cs" Link="WindowsAdapters\DocumentWatcher.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\FileTypeRegistration.cs" Link="WindowsAdapters\FileTypeRegistration.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ShellFileAssociations.cs" Link="WindowsAdapters\ShellFileAssociations.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationRequest.cs" Link="WindowsAdapters\ActivationRequest.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationJsonContext.cs" Link="WindowsAdapters\ActivationJsonContext.cs" />
|
||||
<Compile Include="..\..\src\MarkdownViewr.Windows\ActivationInbox.cs" Link="WindowsAdapters\ActivationInbox.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user