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
6 changes: 5 additions & 1 deletion claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,11 @@ the comment there about not caching "nothing staged" asks for.
are snapshot tested; the others put it on the menu (`TrayActions.Update`). It was a Windows
PowerShell line that did not wait for the tray and updated the global install whichever tray
asked. On Windows the shell stops what runs from the store first, which includes a viewer the
tray started: one that owns the queue is ended without staging it. Seen in a `dotnet/sdk:10.0`
tray started, and one that owns the queue would go without staging it. So the tray, once the
shell has started, finds who listens on the viewer port (`ListenerTable.Owner`, the process id
out of the same row), and if that process runs from somewhere the update stops
(`TrayUpdate.Stops`) sends it `Quit`, which an owning viewer takes as exiting whatever a tray
says, and waits for it to go (`TrayUpdate.CloseOwningViewer`). Seen in a `dotnet/sdk:10.0`
container on a private `dbus-daemon` with a stand-in watcher: Update chosen from the menu, the
tray gone, the install moved from one version to the next, and the new tray registered,
listening and logging what the update said
Expand Down
2 changes: 1 addition & 1 deletion docs/mdsource/tray.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The tray exits, and a shell it started then:

The tray it starts says once how the update went. The `dotnet` used is the one the tray runs on, since a tray started at login may have none on its `PATH`.

On Windows, anything still running from the tray's install is stopped first, since a file in use cannot be replaced. That includes a [viewer](/docs/viewer.md) window the tray opened. A viewer that holds the snapshot queue loses it unstaged, so accept or close what is pending in it before updating.
On Windows, anything still running from the tray's install is stopped first, since a file in use cannot be replaced. That includes a [viewer](/docs/viewer.md) window the tray opened. A viewer from there that holds the snapshot queue is asked to close before the tray exits, and stages what is pending in it as files under each source project's `obj/VerifyInline`, as any viewer does on exit, where an IDE plugin or a text diff tool can still review them.


### Options
Expand Down
2 changes: 1 addition & 1 deletion docs/tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ The tray exits, and a shell it started then:

The tray it starts says once how the update went. The `dotnet` used is the one the tray runs on, since a tray started at login may have none on its `PATH`.

On Windows, anything still running from the tray's install is stopped first, since a file in use cannot be replaced. That includes a [viewer](/docs/viewer.md) window the tray opened. A viewer that holds the snapshot queue loses it unstaged, so accept or close what is pending in it before updating.
On Windows, anything still running from the tray's install is stopped first, since a file in use cannot be replaced. That includes a [viewer](/docs/viewer.md) window the tray opened. A viewer from there that holds the snapshot queue is asked to close before the tray exits, and stages what is pending in it as files under each source project's `obj/VerifyInline`, as any viewer does on exit, where an IDE plugin or a text diff tool can still review them.


### Options
Expand Down
28 changes: 28 additions & 0 deletions src/DiffEngine.Tests/ListenerTableTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,34 @@ public async Task AnIPv6ListenerIsFoundOnItsPort()
}
}

/// <summary>
/// The id at the end of each layout's row, read from the row that names the port: this
/// process, for a listener of either family it holds.
/// </summary>
[Test]
[RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AListenerSaysWhoseItIs()
{
using var current = Process.GetCurrentProcess();
IPAddress[] addresses = Socket.OSSupportsIPv6 ? [IPAddress.Loopback, IPAddress.IPv6Loopback] : [IPAddress.Loopback];
foreach (var address in addresses)
{
var listener = new TcpListener(address, 0);
listener.Start();
var port = ((IPEndPoint) listener.LocalEndpoint).Port;
try
{
await Assert.That(ListenerTable.Owner(port)).IsEqualTo(current.Id);
}
finally
{
listener.Stop();
}

await Assert.That(ListenerTable.Owner(port)).IsNull();
}
}

/// <summary>
/// A port that is bound and not listening has nobody to accept a connection, which is the
/// answer the caller is spared two seconds by, and it takes both tables being read to their
Expand Down
42 changes: 37 additions & 5 deletions src/DiffEngine/Protocol/ListenerTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,30 +110,60 @@ internal static bool Enumerated(int port)
/// loopback connect can reach either. A machine with IPv6 off has no second table to ask for.
/// </para>
/// </summary>
internal static bool? Listeners(int port)
internal static bool? Listeners(int port) =>
Rows(port, out _);

/// <summary>
/// The process listening on <paramref name="port"/>, out of the same table, or null where
/// nothing listens there or the table could not be read, which is everywhere but Windows.
/// For a caller about to do something to that process, so the table not being read is never
/// taken for an answer.
/// </summary>
internal static int? Owner(int port)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}

if (Rows(port, out var owner) == true)
{
return owner;
}

return null;
}

static bool? Rows(int port, out int owner)
{
try
{
// MIB_TCPROW_OWNER_PID, whose third field is the local port
var held = Read(port, interNetwork, 24, 8);
var held = Read(port, interNetwork, 24, 8, out owner);
if (held != false ||
!Socket.OSSupportsIPv6)
{
return held;
}

// MIB_TCP6ROW_OWNER_PID, where the port follows the address and its scope
return Read(port, interNetworkV6, 56, 20);
return Read(port, interNetworkV6, 56, 20, out owner);
}
catch (Exception exception)
when (exception is DllNotFoundException or EntryPointNotFoundException or MarshalDirectiveException)
{
owner = 0;
return null;
}
}

static bool? Read(int port, int family, int rowSize, int portOffset)
/// <summary>
/// Both row layouts end with the owning process's id, which <paramref name="owner"/> is given
/// from the row that names the port.
/// </summary>
static bool? Read(int port, int family, int rowSize, int portOffset, out int owner)
{
owner = 0;
var size = 4096;
// A table that outgrew the buffer says how much it needs, and can have grown again by
// the time it is asked for with that. Not for ever: a call that keeps saying so is one
Expand Down Expand Up @@ -164,9 +194,11 @@ internal static bool Enumerated(int port)
for (var index = 0; index < count; index++)
{
// Network byte order, in the low half of a field twice the size
var at = 4 + index * rowSize + portOffset;
var row = 4 + index * rowSize;
var at = row + portOffset;
if (((table[at] << 8) | table[at + 1]) == port)
{
owner = BitConverter.ToInt32(table, row + rowSize - 4);
return true;
}
}
Expand Down
115 changes: 115 additions & 0 deletions src/DiffEngineTray.Core.Tests/TrayUpdateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,121 @@ public async Task NoUpdateNothingToSay()
await Assert.That(TrayUpdate.Report(file.Path)).IsNull();
}

/// <summary>
/// What the update's Windows script stops, matched as it matches: anything under the store or
/// a folder named beside it, whatever the case, and nothing in a folder that only starts with
/// the same name.
/// </summary>
[Test]
public async Task TheUpdateStopsWhatRunsFromTheStore()
{
var tools = Path.Combine(Path.GetTempPath(), "home", ".dotnet", "tools");
var also = Path.Combine(Path.GetTempPath(), "fixed", "viewer");
var update = new ToolUpdate("DiffEngineTray", Path.Combine(tools, "DiffEngineTray.exe"), Path.Combine(tools, "outcome.log"))
{
AlsoStopUnder = [also]
};
var viewer = Path.Combine(tools, ".store", "diffenginetray", "21.0.0", "diffenginetray.win-x64", "21.0.0", "tools", "net10.0", "win-x64", "viewer", "win-x64", "DiffEngineViewer.exe");

await Assert.That(TrayUpdate.Stops(update, viewer)).IsTrue();
await Assert.That(TrayUpdate.Stops(update, viewer.ToUpperInvariant())).IsTrue();
await Assert.That(TrayUpdate.Stops(update, Path.Combine(also, "DiffEngineViewer.exe"))).IsTrue();
await Assert.That(TrayUpdate.Stops(update, Path.Combine(tools, ".store", "diffenginetray.other", "DiffEngineViewer.exe"))).IsFalse();
await Assert.That(TrayUpdate.Stops(update, Path.Combine(Path.GetTempPath(), ".nuget", "packages", "diffengine", "DiffEngineViewer.exe"))).IsFalse();
await Assert.That(TrayUpdate.Stops(update, null)).IsFalse();
}

/// <summary>
/// A queue this tray holds itself goes with the tray, which stages it as it exits.
/// </summary>
[Test]
[RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AQueueThisTrayHoldsIsNotAskedToClose()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint) listener.LocalEndpoint).Port;
await Assert.That(TrayUpdate.CloseOwningViewer(StoppingUnder(Environment.ProcessPath!), port)).IsFalse();
}
finally
{
listener.Stop();
}
}

/// <summary>
/// The process listening on the port is found by the listener table, matched by its image
/// against what the update stops, sent <c>Quit</c> and waited for. Windows PowerShell stands
/// in for the viewer, saying the first line it is sent, so the update is told to stop under
/// PowerShell's folder.
/// </summary>
[Test]
[RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AViewerTheUpdateStopsIsAskedToClose()
{
using var viewer = StandInViewer(out var port);

var asked = TrayUpdate.CloseOwningViewer(StoppingUnder(StandInTool.Image), port);

await Assert.That(asked).IsTrue();
await Assert.That(viewer.StandardOutput.ReadLine()).IsEqualTo("verb: quit");
await Assert.That(viewer.WaitForExit(10_000)).IsTrue();
}

[Test]
[RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AViewerTheUpdateLeavesAloneIsLeftAlone()
{
using var viewer = StandInViewer(out var port);
try
{
var asked = TrayUpdate.CloseOwningViewer(StoppingUnder(Path.Combine(Path.GetTempPath(), "elsewhere", "DiffEngineViewer.exe")), port);

await Assert.That(asked).IsFalse();
await Assert.That(viewer.HasExited).IsFalse();
}
finally
{
viewer.Kill();
}
}

static ToolUpdate StoppingUnder(string image)
{
var tools = Path.Combine(Path.GetTempPath(), "TrayUpdateTests", "tools");
return new("DiffEngineTray", Path.Combine(tools, "DiffEngineTray.exe"), Path.Combine(tools, "outcome.log"))
{
AlsoStopUnder = [Path.GetDirectoryName(image)!]
};
}

/// <summary>
/// Listens on a port of its own, says which, and says the verb line of what a connection sends
/// before it exits.
/// </summary>
static Process StandInViewer(out int port)
{
const string script =
"$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0); " +
"$listener.Start(); " +
"[Console]::WriteLine($listener.LocalEndpoint.Port); " +
"$client = $listener.AcceptTcpClient(); " +
"$reader = [System.IO.StreamReader]::new($client.GetStream()); " +
"do { $line = $reader.ReadLine() } until ($line -eq $null -or $line.StartsWith('verb:')); " +
"[Console]::WriteLine($line)";
var process = Process.Start(
new ProcessStartInfo(StandInTool.Image, ["-NoProfile", "-Command", script])
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true
})!;
port = int.Parse(process.StandardOutput.ReadLine()!);
return process;
}

sealed class TempOutcome : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"TrayUpdateTests.{Guid.NewGuid():N}.log");
Expand Down
2 changes: 1 addition & 1 deletion src/DiffEngineTray.Core/ProcessEx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ exe is not null &&
/// Asked of the handle that is held, so it is the process that would be ended that answers,
/// and through the call that needs the least access to it. Off Windows, of the id.
/// </summary>
static string? ImagePath(Process process)
internal static string? ImagePath(Process process)
{
if (OperatingSystem.IsLinux())
{
Expand Down
79 changes: 78 additions & 1 deletion src/DiffEngineTray.Core/TrayUpdate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ static class TrayUpdate

/// <summary>
/// Starts the update, then exits the tray, which the update waits for. A shell that would not
/// start leaves the tray running, since nothing would start it again.
/// start leaves the tray running, since nothing would start it again, and leaves the viewer
/// holding the queue alone, since nothing would stop it.
/// </summary>
public static void Start(ToolUpdate update, Action exit)
{
Expand All @@ -63,9 +64,85 @@ public static void Start(ToolUpdate update, Action exit)
return;
}

// While the shell waits for this tray, which it does for longer than the viewer is given
if (OperatingSystem.IsWindows())
{
CloseOwningViewer(update, ViewerClient.Port);
}

exit();
}

/// <summary>
/// How long a viewer asked to close before an update has to stage its queue and go. Its close
/// finishes an accept-all under way and gives each of its listeners two seconds to stop.
/// </summary>
static readonly TimeSpan closeWait = TimeSpan.FromSeconds(15);

/// <summary>
/// On Windows the update stops whatever runs from the store before it updates, and a viewer
/// started from this tray's own viewer folder runs from there. One that holds the inline queue
/// holds it in memory and stages it only as it exits, so stopped, its snapshots were gone. So
/// it is asked to close, which an owning viewer takes as exiting (<c>Quit</c>), and waited for.
/// A viewer from anywhere else is left alone, since nothing stops it.
/// </summary>
/// <returns>Whether a viewer was asked to close.</returns>
internal static bool CloseOwningViewer(ToolUpdate update, int port)
{
if (ListenerTable.Owner(port) is not { } id ||
id == Environment.ProcessId ||
!ProcessEx.TryGet(id, out var process))
{
return false;
}

using (process)
{
var image = ProcessEx.ImagePath(process);
if (!Stops(update, image))
{
return false;
}

Log.Information("Asking the viewer that holds the queue, {Image}, to stage it and close before the update stops it", image);
if (!ViewerClient.TrySend(new(ViewerVerb.Quit), out _, port))
{
Log.Warning("The viewer that holds the queue did not answer, and the update will stop it");
return true;
}

if (!process.WaitForExit(closeWait))
{
Log.Warning("The viewer that holds the queue had not closed after {Wait}, and the update will stop it", closeWait);
}

return true;
}
}

/// <summary>
/// Whether the update stops a process running <paramref name="image" />: one under the store,
/// or under a folder it was told to stop as well. As the update's script matches them, without
/// regard to case, and by whole folders.
/// </summary>
internal static bool Stops(ToolUpdate update, string? image)
{
if (image is null)
{
return false;
}

foreach (var directory in update.AlsoStopUnder.Prepend(update.StoreDirectory))
{
if (image.StartsWith(Path.TrimEndingDirectorySeparator(directory) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}

/// <summary>
/// What to say about the update that started this tray, or null where none did, which is every
/// start but the one after an update. Said once, since reading it removes it, and only by a
Expand Down
Loading
Loading