Files
Explorer-Workbench/src/Explorer.Hosting/Ipc/WorkbenchPipeClient.cs
netquick 7c23bc2474 Extract a per-user background host so indexing can later run outside the window.
Keep the GUI as the index writer for now; mutex, named pipe, and opt-in logon autostart prepare Explorer.Host.exe without two SQLite writers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 17:10:49 +02:00

277 lines
9.9 KiB
C#

using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using Explorer.Contracts;
using Explorer.Domain;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
{
private readonly NamedPipeClientStream _pipe;
private readonly StreamWriter _writer;
private readonly StreamReader _reader;
private readonly SemaphoreSlim _send = new(1, 1);
private readonly ConcurrentDictionary<string, TaskCompletionSource<IpcEnvelope>> _pending = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _readLoop;
private readonly IndexingProxy _indexing;
private readonly TransferProxy _transfers;
private WorkbenchPipeClient(NamedPipeClientStream pipe)
{
_pipe = pipe;
_writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true, NewLine = "\n" };
_reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
_indexing = new IndexingProxy(this);
_transfers = new TransferProxy(this);
_readLoop = ReadLoopAsync(_cts.Token);
}
public IIndexingHost Indexing => _indexing;
public ITransferHost Transfers => _transfers;
public static async Task<WorkbenchPipeClient> ConnectAsync(
WorkbenchIpcOptions options,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
var deadline = DateTime.UtcNow + timeout;
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
cancellationToken.ThrowIfCancellationRequested();
var pipe = new NamedPipeClientStream(
".",
options.PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
try
{
var remaining = deadline - DateTime.UtcNow;
if (remaining < TimeSpan.FromMilliseconds(50))
{
remaining = TimeSpan.FromMilliseconds(50);
}
await pipe.ConnectAsync(remaining, cancellationToken).ConfigureAwait(false);
var client = new WorkbenchPipeClient(pipe);
await client.CallAsync("Ping", cancellationToken).ConfigureAwait(false);
return client;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
last = ex;
await pipe.DisposeAsync().ConfigureAwait(false);
try
{
await Task.Delay(80, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
}
}
throw new TimeoutException(
$"Could not connect to Explorer Workbench host pipe '{options.PipeName}'.", last);
}
internal IpcEnvelope Call(string op, long? n = null, string? s = null)
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
internal async Task<IpcEnvelope> CallAsync(
string op,
CancellationToken cancellationToken,
long? n = null,
string? s = null)
{
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = tcs;
var request = new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Id = id, Op = op, N = n, S = s };
var json = JsonSerializer.Serialize(request, WorkbenchIpc.Json);
await _send.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
catch
{
_pending.TryRemove(id, out _);
throw;
}
finally
{
_send.Release();
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token, _cts.Token);
using var cancelReg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
try
{
var reply = await tcs.Task.ConfigureAwait(false);
if (reply.Ok == false)
{
throw new InvalidOperationException(reply.Error ?? "Host call failed.");
}
return reply;
}
finally
{
_pending.TryRemove(id, out _);
}
}
internal void RaiseProgress(ScanProgress progress) => _indexing.Raise(progress);
internal void RaiseChanged() => _transfers.RaiseChanged();
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
IpcEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<IpcEnvelope>(line, WorkbenchIpc.Json);
}
catch (JsonException)
{
continue;
}
if (envelope is null)
{
continue;
}
if (!string.IsNullOrEmpty(envelope.Evt))
{
switch (envelope.Evt)
{
case "Indexing.Progress" when envelope.Progress is not null:
RaiseProgress(envelope.Progress);
break;
case "Transfers.Changed":
RaiseChanged();
break;
case "Transfers.JobFinished" when envelope.Job is not null:
RaiseFinished(envelope.Job);
break;
}
continue;
}
if (envelope.Id is not null && _pending.TryRemove(envelope.Id, out var tcs))
{
tcs.TrySetResult(envelope);
}
}
}
catch (OperationCanceledException)
{
// shutting down
}
finally
{
foreach (var tcs in _pending.Values)
{
tcs.TrySetCanceled(cancellationToken);
}
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync().ConfigureAwait(false);
try
{
await _writer.DisposeAsync().ConfigureAwait(false);
}
catch
{
// pipe already closed
}
_reader.Dispose();
await _pipe.DisposeAsync().ConfigureAwait(false);
try
{
await _readLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
catch (TimeoutException)
{
// reader may still be unwinding after the pipe close
}
catch (OperationCanceledException)
{
// expected
}
_cts.Dispose();
_send.Dispose();
}
private sealed class IndexingProxy : IIndexingHost
{
private readonly WorkbenchPipeClient _client;
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public IndexingProxy(WorkbenchPipeClient client) => _client = client;
public void EnqueueFullScan(long sourceId) => _client.Call("Indexing.EnqueueFullScan", sourceId);
public void EnqueueFolderScan(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
public void EnqueueReconcile(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel);
public void Cancel(long sourceId) => _client.Call("Indexing.Cancel", sourceId);
public void Raise(ScanProgress progress) => ProgressChanged?.Invoke(this, progress);
}
private sealed class TransferProxy : ITransferHost
{
private readonly WorkbenchPipeClient _client;
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public TransferProxy(WorkbenchPipeClient client) => _client = client;
public bool IsPaused => _client.Call("Transfers.IsPaused").Paused == true;
public IReadOnlyList<TransferJob> Snapshot()
=> _client.Call("Transfers.Snapshot").Jobs ?? [];
public void PauseAll() => _client.Call("Transfers.PauseAll");
public void ResumeAll() => _client.Call("Transfers.ResumeAll");
public void Pause(long jobId) => _client.Call("Transfers.Pause", jobId);
public void Resume(long jobId) => _client.Call("Transfers.Resume", jobId);
public void Retry(long jobId) => _client.Call("Transfers.Retry", jobId);
public void Cancel(long jobId) => _client.Call("Transfers.Cancel", jobId);
public void Dismiss(long jobId) => _client.Call("Transfers.Dismiss", jobId);
public void ClearFinished() => _client.Call("Transfers.ClearFinished");
public bool MoveUp(long jobId) => _client.Call("Transfers.MoveUp", jobId).Flag == true;
public bool MoveDown(long jobId) => _client.Call("Transfers.MoveDown", jobId).Flag == true;
public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
}
}