Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(BUILD_GAMES OFF CACHE BOOL "" FORCE)
set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE)
# raylib spins for the last part of every frame by default, for timing precision nothing here
# needs: most of a millisecond of a core per frame, for as long as the viewer is open.
set(SUPPORT_PARTIALBUSY_WAIT_LOOP OFF CACHE BOOL "" FORCE)
set(SUPPORT_MODULE_RAUDIO OFF CACHE BOOL "" FORCE)
set(SUPPORT_MODULE_RMODELS OFF CACHE BOOL "" FORCE)
# The formats the viewer compares as pictures and raylib can decode. WebP and ICO are also
Expand Down
3 changes: 2 additions & 1 deletion src/DiffEngine.Tests/OsSettingsResolverTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public class OsSettingsResolverTest
[Test]
public async Task PathEntriesAreUnquotedAndEmptiesDropped()
{
var paths = OsSettingsResolver.ParsePath(@"C:\one;""C:\Program Files\two"" ; ;C:\thr|ee;", ';');
// NUL rather than a character like |, which is only invalid on Windows
var paths = OsSettingsResolver.ParsePath(@"C:\one;""C:\Program Files\two"" ; ;C:\thr" + "\0" + "ee;", ';');

await Assert.That(paths).IsEquivalentTo([@"C:\one", @"C:\Program Files\two"]);
}
Expand Down
34 changes: 34 additions & 0 deletions src/DiffEngine/Inline/InlineStaging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,41 @@ static bool IsOneCallSite(List<(string PatchPath, InlinePatch Patch)> staged)
return staged.All(_ => _.Patch.LineHint == line);
}

/// <summary>
/// Read again only when the directory has changed since. <see cref="Clear" /> runs once per
/// verification and used to read and parse every staged patch each time, so a run with a few
/// hundred staged was reading them a few hundred times over. Creating or deleting a trio is
/// what changes a directory's write time, and overwriting one keeps the name - which is derived
/// from the call site and framework that matching reads - so what is cached still matches.
/// </summary>
static List<(string PatchPath, InlinePatch Patch)> ReadStaged(string directory)
{
DateTime written;
try
{
written = Directory.GetLastWriteTimeUtc(directory);
}
catch (Exception exception)
when (exception is IOException or UnauthorizedAccessException)
{
return [];
}

if (stagedCache.TryGetValue(directory, out var cached) &&
cached.Written == written)
{
return cached.Patches;
}

var patches = ReadStagedFiles(directory);
stagedCache[directory] = (written, patches);
return patches;
}

static readonly ConcurrentDictionary<string, (DateTime Written, List<(string PatchPath, InlinePatch Patch)> Patches)> stagedCache =
new(StringComparer.OrdinalIgnoreCase);

static List<(string PatchPath, InlinePatch Patch)> ReadStagedFiles(string directory)
{
var result = new List<(string, InlinePatch)>();
string[] files;
Expand Down
8 changes: 8 additions & 0 deletions src/DiffEngine/Process/ProcessCleanup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ public static void Kill(string command)
Logging.Write($"Kill: {command}. Matching count: {matchingCommands.Count}");
if (matchingCommands.Count == 0)
{
// Only when someone will read it. This is the usual outcome of a passing verification,
// and on Linux and macOS the list is every process the user owns, so the joined string
// was hundreds of KB of garbage per test with logging off.
if (!Logging.enabled)
{
return;
}

var separator = Environment.NewLine + "\t";
var joined = string.Join(separator, Commands.Select(_ => _.Command));
Logging.Write($"No matching commands. All commands: {separator}{joined}.");
Expand Down
52 changes: 52 additions & 0 deletions src/DiffEngine/Tray/PiperClient.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net.NetworkInformation;
static class PiperClient
{
public static int Port = 3492;
Expand Down Expand Up @@ -86,6 +87,12 @@ public static string BuildMovePayload(string tempFile, string targetFile, string
/// </summary>
static bool Send(string payload)
{
if (!PortIsHeld())
{
HandleNoListener(payload);
return false;
}

try
{
InnerSend(payload);
Expand All @@ -100,6 +107,14 @@ static bool Send(string payload)

static async Task<bool> SendAsync(string payload, Cancel cancel)
{
// Before the listener check, so a cancelled send says so whether or not a tray is there
cancel.ThrowIfCancellationRequested();
if (!PortIsHeld())
{
HandleNoListener(payload);
return false;
}

try
{
await InnerSendAsync(payload, cancel);
Expand All @@ -123,6 +138,17 @@ static async Task<bool> SendAsync(string payload, Cancel cancel)
}
}

static void HandleNoListener(string payload) =>
Trace.WriteLine(
$"""
Failed to send payload to DiffEngineTray.

Payload:
{payload}

Nothing is listening on the tray's port.
""");

static void HandleSendException(string payload, Exception exception) =>
Trace.WriteLine(
$"""
Expand Down Expand Up @@ -188,6 +214,32 @@ static async Task InnerSendAsync(string payload, Cancel cancel)
}
}

/// <summary>
/// Whether anything is listening, asked of the OS rather than found out by connecting. Whether
/// a tray runs is read once per process, so after it exits every move and delete still came
/// here, and a connect to a port nobody holds is not refused at once everywhere: where the SYN
/// is dropped it runs to its timeout, two seconds a send. The listener table answers in well
/// under a millisecond. A table that cannot be read leaves the connect to decide.
/// </summary>
static bool PortIsHeld()
{
try
{
var port = Port;
return IPGlobalProperties.GetIPGlobalProperties()
.GetActiveTcpListeners()
.Any(_ => _.Port == port);
}
catch (NetworkInformationException)
{
return true;
}
catch (PlatformNotSupportedException)
{
return true;
}
}

static IPEndPoint GetEndpoint() =>
new(IPAddress.Loopback, Port);
}
12 changes: 2 additions & 10 deletions src/DiffEngineTray.Tests/PiperTest.SendOnly.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ Payload:
"ProcessId":10
}

Exception:
System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it.
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
at System.Net.Sockets.TcpClient.CompleteConnectAsync(ValueTask task),
Nothing is listening on the tray's port.,
Failed to send payload to DiffEngineTray.

Payload:
Expand All @@ -23,9 +19,5 @@ Payload:
}


Exception:
System.Net.Sockets.SocketException (10061): No connection could be made because the target machine actively refused it.
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
at System.Net.Sockets.TcpClient.CompleteConnectAsync(ValueTask task)
Nothing is listening on the tray's port.
]
64 changes: 64 additions & 0 deletions src/DiffEngineTray/Tracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,27 @@ void RemoveAndKill(TrackedMove tacked)
{
return;
}
// A pair found different, and untouched since, is still different. The scan runs every two
// seconds and read both files through again each time, which for a few large same-size
// pairs - bitmaps, fixed-size data - was hundreds of megabytes a scan for as long as they
// stayed pending.
var stamp = Stamp(move);
if (stamp is not null &&
differing.TryGetValue(move.Temp, out var known) &&
known == stamp)
{
return;
}

try
{
if (!await FileComparer.FilesAreEqual(move.Temp, move.Target))
{
if (stamp is not null)
{
differing[move.Temp] = stamp.Value;
}

return;
}
}
Expand All @@ -99,6 +116,23 @@ void RemoveAndKill(TrackedMove tacked)
RemoveAndKill(pair.Value);
}

readonly ConcurrentDictionary<string, (long, DateTime, long, DateTime)> differing = new(StringComparer.OrdinalIgnoreCase);

static (long, DateTime, long, DateTime)? Stamp(TrackedMove move)
{
try
{
var temp = new FileInfo(move.Temp);
var target = new FileInfo(move.Target);
return (temp.Length, temp.LastWriteTimeUtc, target.Length, target.LastWriteTimeUtc);
}
catch (Exception exception)
when (exception is IOException or UnauthorizedAccessException)
{
return null;
}
}

void ToggleActive()
{
if (TrackingAny)
Expand Down Expand Up @@ -613,6 +647,15 @@ bool InnerMove(TrackedMove move, AcceptBatch batch)
return true;
}

// Nothing waiting will change these, and every retry is another 400ms of a frozen
// menu - an accept-all in a read-only workspace sat through all eight for every move
if (CannotEverMove(move))
{
Log.Warning("Could not accept `{Name}`: the target is read-only or its directory is missing. Kept pending", move.Name);
acceptFailed?.Invoke(move);
return false;
}

var locked = FindLockedFiles(move);
if (locked == null)
{
Expand Down Expand Up @@ -645,6 +688,27 @@ bool InnerMove(TrackedMove move, AcceptBatch batch)
return false;
}

static bool CannotEverMove(TrackedMove move)
{
try
{
var directory = Path.GetDirectoryName(move.Target);
if (directory is not null &&
!Directory.Exists(directory))
{
return true;
}

return File.Exists(move.Target) &&
new FileInfo(move.Target).IsReadOnly;
}
catch (Exception exception)
when (exception is IOException or UnauthorizedAccessException)
{
return false;
}
}

bool ShouldKill(TrackedMove move, LockedFiles locked, AcceptBatch batch)
{
if (move.KillLockingProcess ||
Expand Down
22 changes: 21 additions & 1 deletion src/DiffEngineViewer.Windows/FormsViewerWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,30 @@ public bool Present(Screen screen)
return false;
}

Thread.Sleep(frameMilliseconds);
Wait();
return true;
}

/// <summary>
/// Until the next frame is due or input arrives, whichever is first. Thread.Sleep(16) woke on
/// the default 15.6ms timer tick after the one it asked for, so about 31ms: half the frame
/// rate, with every key and click waiting out the rest of it. 15 lands on the next tick, and
/// input ends the wait at once. Hidden, which a tray keeps it for days, there is nothing to
/// draw, so it wakes a few times a second rather than sixty.
/// </summary>
void Wait()
{
var timeout = form.Visible ? frameMilliseconds - 1 : hiddenMilliseconds;
MsgWaitForMultipleObjectsEx(0, IntPtr.Zero, (uint) timeout, allInput, inputAvailable);
}

const int hiddenMilliseconds = 100;
const uint allInput = 0x04FF;
const uint inputAvailable = 0x0004;

[DllImport("user32.dll")]
static extern uint MsgWaitForMultipleObjectsEx(uint count, IntPtr handles, uint milliseconds, uint wakeMask, uint flags);

public ViewerInput Poll() =>
form.IsDisposed ? default : form.Drain();

Expand Down
3 changes: 2 additions & 1 deletion src/DiffEngineViewer.Windows/ViewerCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds)
Cellular(bounds.X, bounds.Y, gutter, bounds.Height));
Painter.Draw(
graphics,
RowText.Flatten(row.Text),
// No wider than the pane can show in pixels, which no line of characters can exceed
RowText.Clip(RowText.Flatten(row.Text), bounds.Width),
font,
Palette.Foreground(row.Kind),
Cellular(bounds.X + gutter, bounds.Y, bounds.Width - gutter, bounds.Height));
Expand Down
14 changes: 12 additions & 2 deletions src/DiffEngineViewer/DiffRows.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using DiffPlex;
using DiffPlex.Chunkers;
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;

Expand All @@ -14,14 +16,22 @@ public static (IReadOnlyList<Row> Left, IReadOnlyList<Row> Right) Build(string l
// on indentation or a trailing space came back Unchanged on every row, so the panes drew no
// markers, NextChange found nothing, and the reviewer was shown a failure with no visible
// difference. Whitespace is exactly what the F# layout convention is about.
var model = SideBySideDiffBuilder.Diff(
var model = builder.BuildDiffModel(
rightText,
leftText,
ignoreWhiteSpace: false,
ignoreWhitespace: false,
ignoreCase: false);
return (Convert(model.NewText.Lines), Convert(model.OldText.Lines));
}

/// <summary>
/// Lines chunked into lines, and each line's words into the whole line. The builder diffs the
/// words of every modified pair to fill SubPieces, which nothing here reads, and that pass
/// grows with the square of the line length - minified JSON, a base64 blob. A line that is its
/// own single word leaves the rows identical and the pass trivial.
/// </summary>
static readonly SideBySideDiffBuilder builder = new(Differ.Instance, LineChunker.Instance, LineChunker.Instance);

static List<Row> Convert(List<DiffPiece> lines)
{
var rows = new List<Row>(lines.Count);
Expand Down
24 changes: 24 additions & 0 deletions src/DiffEngineViewer/Model/RowText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@
/// </summary>
static class RowText
{
/// <summary>
/// No more of a row than can be on screen. Nothing scrolls horizontally, so a character past
/// the window's width is never drawn - but a renderer still laid out and measured the whole
/// line, and a one megabyte minified line cost most of a second a paint. Every character is
/// at least one cell wide, so the window's width in characters is always enough. Never ends
/// between the halves of a surrogate pair.
/// </summary>
public static string Clip(string text, int cells)
{
if (text.Length <= cells)
{
return text;
}

var length = Math.Max(0, cells);
if (length > 0 &&
char.IsHighSurrogate(text[length - 1]))
{
length--;
}

return text[..length];
}

public static string Flatten(string text)
{
if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0)
Expand Down
Loading
Loading