Cut over the window to Explorer.Host.exe so only the host writes the index.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 17:39:13 +02:00
parent 7c23bc2474
commit 48d03f794f
26 changed files with 774 additions and 59 deletions

View File

@@ -11,9 +11,13 @@ public class WorkbenchHostTests
{
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
IWorkbenchHost host = new WorkbenchHost(indexing, transfers);
var sources = new StubSources();
var mutations = new StubMutations();
IWorkbenchHost host = new WorkbenchHost(indexing, transfers, sources, mutations);
Assert.Same(indexing, host.Indexing);
Assert.Same(transfers, host.Transfers);
Assert.Same(sources, host.Sources);
Assert.Same(mutations, host.Mutations);
host.Indexing.EnqueueFullScan(7);
host.Transfers.PauseAll();
@@ -57,4 +61,30 @@ public class WorkbenchHostTests
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -109,12 +109,14 @@ public class FolderSyncServiceTests
store,
volumes,
NullLogger<TransferQueue>.Instance);
var mutations = new LocalIndexMutations(store);
var ops = new FileOperationService(queue, shell, enumerator);
var env = new SyncEnv(root);
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
var sync = new FolderSyncService(
new FolderSyncPlanner(),
store,
mutations,
sources,
ops,
volumes,

View File

@@ -107,10 +107,12 @@ public class OperationProfileServiceTests
var ops = new FileOperationService(queue, shell, enumerator);
var git = new StubGit();
var planner = new FileOperationProfilePlanner(new RenamePlanner());
var renames = new RenameBatchService(new RenamePlanner(), store, ops);
var mutations = new LocalIndexMutations(store);
var renames = new RenameBatchService(new RenamePlanner(), store, mutations, ops);
var profiles = new OperationProfileService(
planner,
store,
mutations,
ops,
renames,
volumes,

View File

@@ -316,7 +316,7 @@ public class TransferQueueTests
{
await using var ctx = await Harness.CreateAsync();
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, ops);
var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, new LocalIndexMutations(ctx.Store), ops);
await ctx.Queue.StartAsync(CancellationToken.None);
var subjects = new[]
{

View File

@@ -3,6 +3,7 @@ using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting.Tests;
@@ -23,6 +24,7 @@ public class CoreRegistrationTests
Assert.NotNull(sp.GetService<IWorkbenchHost>());
Assert.NotNull(sp.GetService<IIndexingHost>());
Assert.NotNull(sp.GetService<ITransferHost>());
Assert.NotNull(sp.GetService<IIndexMutations>());
Assert.Null(sp.GetService<IOsClipboard>());
}
finally
@@ -31,6 +33,35 @@ public class CoreRegistrationTests
}
}
[Fact]
public async Task AddExplorerClient_uses_read_only_store_without_indexing_workers()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var workbench = new StubWorkbench();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IAppEnvironment>(new TempEnv(dir));
services.AddExplorerClient(workbench);
await using var sp = services.BuildServiceProvider();
Assert.Same(workbench, sp.GetService<IWorkbenchHost>());
Assert.Same(workbench.Indexing, sp.GetService<IIndexingHost>());
Assert.Same(workbench.Transfers, sp.GetService<ITransferHost>());
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
var hosted = sp.GetServices<IHostedService>().ToList();
Assert.Contains(hosted, s => s is IndexStoreLifetime);
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService");
}
finally
{
try { Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
private sealed class TempEnv : IAppEnvironment
{
public TempEnv(string dir)
@@ -45,4 +76,65 @@ public class CoreRegistrationTests
public string DatabasePath { get; }
public string LogDirectory { get; }
}
private sealed class StubWorkbench : IWorkbenchHost
{
public IIndexingHost Indexing { get; } = new StubIndexing();
public ITransferHost Transfers { get; } = new StubTransfers();
public ISourceHost Sources { get; } = new StubSources();
public IIndexMutations Mutations { get; } = new StubMutations();
}
private sealed class StubIndexing : IIndexingHost
{
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public void EnqueueFullScan(long sourceId) { }
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
}
private sealed class StubTransfers : ITransferHost
{
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => false;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() { }
public void ResumeAll() { }
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -14,7 +14,7 @@ public class WorkbenchPipeTests
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
var server = new WorkbenchPipeServer(
new WorkbenchHost(indexing, transfers),
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()),
new WorkbenchIpcOptions { PipeName = "ew-test" },
NullLogger<WorkbenchPipeServer>.Instance);
@@ -31,13 +31,23 @@ public class WorkbenchPipeTests
var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" });
Assert.True(paused.Paused);
var copy = server.Handle(new IpcEnvelope
{
V = WorkbenchIpc.ProtocolVersion,
Op = "Transfers.EnqueueCopy",
Paths = ["C:\\a.txt"],
Dest = "D:\\"
});
Assert.True(copy.Ok);
Assert.Equal(@"D:\", transfers.CopiedDest);
}
[Fact]
public void Handle_rejects_other_protocol_versions()
{
var server = new WorkbenchPipeServer(
new WorkbenchHost(new FakeIndexing(), new FakeTransfers()),
new WorkbenchHost(new FakeIndexing(), new FakeTransfers(), new StubSources(), new StubMutations()),
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
@@ -72,5 +82,37 @@ public class WorkbenchPipeTests
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
public string? CopiedDest { get; private set; }
public Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
{
CopiedDest = destinationDirectory;
return Task.CompletedTask;
}
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}

View File

@@ -451,4 +451,24 @@ public class IndexStoreLockTests
Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path));
Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path));
}
[Fact]
public async Task Read_only_store_opens_while_writer_holds_the_mutex()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
await writer.AddSourceAsync(@"C:\data");
await using var reader = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance, readOnly: true);
await reader.OpenAsync();
Assert.False(reader.CanWrite);
var sources = await reader.Sources.GetAllAsync();
Assert.Single(sources);
Assert.Equal(@"C:\data", sources[0].LastRootPath);
var write = await Assert.ThrowsAsync<InvalidOperationException>(
() => reader.RunWriteAsync(_ => Task.CompletedTask));
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
}
}