Compare commits

...
1 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 1c680d7c02 fix(activation): always open the launched document and start without the inbox when it is unavailable
node / node (push) Successful in 24s
build / scripts (push) Successful in 24s
windows / windows (push) Successful in 2m5s
build / windows (push) Successful in 2m5s
The reader created its activation pipe outside any error handling, so it
could not start while the pipe name was held by a reader that had not
finished closing or by another program. It now starts without an inbox,
and releases the pipe before the instance key so the next reader can
create its own.

A launch whose request the running reader does not take (no listener, a
refusal, or a deadline) used to exit with code 1 and no window. It now
opens its documents in its own process. An argument that cannot name a
document is dropped and reported while the usable ones still open.

The request, schema and inbox move to Core/Activation, inside the
coverage gate and the core test run. Deadlines run on a TimeProvider:
a sender waits two seconds for the pipe and each side ten seconds for one
exchange, and the tests reach both without waiting. The listener's
disconnect and Dispose share a lock instead of catching the race between
them.

ADR-0005, the threat model, the fuzzing guide and the GitHub shadow
workflows follow the move.

Closes #52

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 17:51:51 -06:00
21 changed files with 936 additions and 474 deletions

No files matched your search

+1 -1
View File
@@ -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
-9
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -2
View File
@@ -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'
```
+7
View File
@@ -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
@@ -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();
}
}
@@ -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);
}
@@ -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;
+44 -15
View File
@@ -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;
}
}
@@ -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>