From 596abe73e22ad3afcd635380113a0b6621541b83 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 10:01:39 +1000 Subject: [PATCH 01/10] Report a capped inline launch as no viewer AddInlineAsync read every launch outcome but Failed as Queued. That was right until the gate gained Capped, for a launch MaxInstance declined: nothing was started and nothing took the patch, yet the caller was told the snapshot had been queued, so it staged nothing and the snapshot was pending nowhere. With no tray running, every inline snapshot failing after the fifth diff tool of a run went that way, and with DiffEngine_MaxInstances=0 every one did. Only Launched and Taken now read as Queued. Capped joins Failed as NoViewerFound, the answer that has the caller stage the snapshot. OnlyALaunchOrAHandoverIsQueued pins the mapping for all four outcomes. --- src/DiffEngine.Tests/ViewerLaunchGateTests.cs | 14 +++++++++++++ src/DiffEngine/DiffRunner_Inline.cs | 20 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/DiffEngine.Tests/ViewerLaunchGateTests.cs b/src/DiffEngine.Tests/ViewerLaunchGateTests.cs index f3eae6f7..ffc02490 100644 --- a/src/DiffEngine.Tests/ViewerLaunchGateTests.cs +++ b/src/DiffEngine.Tests/ViewerLaunchGateTests.cs @@ -170,6 +170,20 @@ public async Task NoSlotMeansNoViewerIsStartedAsync() await Assert.That(viewer.Starts).IsEqualTo(0); } + /// + /// What AddInlineAsync tells its caller about each outcome. Capped is the one that matters: + /// nothing was started and nothing took the patch, and reporting that as queued meant the + /// caller staged nothing either, so the snapshot was pending nowhere. + /// + [Test] + public async Task OnlyALaunchOrAHandoverIsQueued() + { + await Assert.That(DiffRunner.InlineResultFor(ViewerLaunchOutcome.Launched)).IsEqualTo(InlineResult.Queued); + await Assert.That(DiffRunner.InlineResultFor(ViewerLaunchOutcome.Taken)).IsEqualTo(InlineResult.Queued); + await Assert.That(DiffRunner.InlineResultFor(ViewerLaunchOutcome.Capped)).IsEqualTo(InlineResult.NoViewerFound); + await Assert.That(DiffRunner.InlineResultFor(ViewerLaunchOutcome.Failed)).IsEqualTo(InlineResult.NoViewerFound); + } + /// /// A slot is spent on a window, not on a pair. So the cap is asked only once the ownership /// probe has said there is no window - otherwise the nineteen callers that find the one their diff --git a/src/DiffEngine/DiffRunner_Inline.cs b/src/DiffEngine/DiffRunner_Inline.cs index 42a64f45..0d1f28c3 100644 --- a/src/DiffEngine/DiffRunner_Inline.cs +++ b/src/DiffEngine/DiffRunner_Inline.cs @@ -88,9 +88,27 @@ public static async Task AddInlineAsync(InlinePatch patch, Cancel async () => await ViewerClient.SendAsync(new(ViewerVerb.Inline, Body: payload), cancel) == SendOutcome.Accepted, () => ViewerLauncher.LaunchAsync(patch, payload, cancel), cancel); - return launched == ViewerLaunchOutcome.Failed ? InlineResult.NoViewerFound : InlineResult.Queued; + return InlineResultFor(launched); } + /// + /// Queued only where something now holds the snapshot: the viewer this call started, or an + /// owner that turned up while it waited at the gate. + /// + /// A capped launch started nothing, and nobody was there to take the patch, so it is pending + /// nowhere - the same position as a viewer that could not be found, and answered the same way + /// so the caller stages it. Everything but Failed used to read as queued, which was right until + /// Capped existed and wrong from then on: with no tray running, every inline snapshot failing + /// after the fifth diff tool of a run was reported as handed over and staged by nobody. + /// + /// + internal static InlineResult InlineResultFor(ViewerLaunchOutcome outcome) => + outcome switch + { + ViewerLaunchOutcome.Launched or ViewerLaunchOutcome.Taken => InlineResult.Queued, + _ => InlineResult.NoViewerFound + }; + /// /// Drops a pending inline snapshot from the viewer's queue, for when a previously failing test /// starts passing. Does nothing when no viewer is running - and cheaply, since this is called From 2a2ab9ffcff51da43e519ea206b7c90e4eb65f7a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 10:01:39 +1000 Subject: [PATCH 02/10] Keep the source file when File.Replace fails after removing it InlineApplier writes the patched source to a sibling temporary, swaps it in with File.Replace, and deletes the temporary in a finally. ReplaceFile can fail after it has already taken the destination away: with no backup name, ERROR_UNABLE_TO_MOVE_REPLACEMENT means the original no longer exists and the replacement is still under its temporary name - which the finally then deleted, leaving no copy of the source file anywhere. An antivirus or sync client holding the freshly written temporary is what produces it. When the swap fails with the destination gone and the temporary still there, the temporary is now moved into place. It is the whole patched file, so that completes the write. The temporary is only deleted while the destination exists, and if moving it back fails too, the error names where the source went. The swap is supplied by the tests, since the failure cannot be arranged on demand. AReplaceThatFailsAfterRemovingTheSourceStillLeavesOne deletes the destination and throws, and finds the patched content in place with nothing left beside it. AReplaceThatFailsCleanlyLeavesTheSourceAsItWas throws without touching either file, and finds the original. --- src/DiffEngine.Tests/InlineApplierTests.cs | 57 ++++++++++++++++++++++ src/DiffEngine/Inline/InlineApplier.cs | 51 +++++++++++++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/DiffEngine.Tests/InlineApplierTests.cs b/src/DiffEngine.Tests/InlineApplierTests.cs index 034b01da..95403e0c 100644 --- a/src/DiffEngine.Tests/InlineApplierTests.cs +++ b/src/DiffEngine.Tests/InlineApplierTests.cs @@ -188,6 +188,63 @@ public async Task LeavesNoTemporaryBehind() } } + // ReplaceFile can fail after the destination has already gone: with no backup name, + // ERROR_UNABLE_TO_MOVE_REPLACEMENT leaves the original deleted and the replacement under its + // temporary name. The temporary is then the only copy of the source there is, and deleting it + // on the way out lost the file outright + [Test] + public async Task AReplaceThatFailsAfterRemovingTheSourceStillLeavesOne() + { + var directory = NewDirectory(); + try + { + var path = Path.Combine(directory, "Sample.cs"); + await File.WriteAllTextAsync(path, "original"); + + InlineApplier.WriteThroughTemporary( + path, + Encoding.UTF8.GetBytes("patched"), + (_, destination) => + { + File.Delete(destination); + throw new IOException("Unable to move the replacement file to the file to be replaced."); + }); + + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo("patched"); + await Assert.That(Directory.GetFileSystemEntries(directory)).IsEquivalentTo([path]); + } + finally + { + Directory.Delete(directory, true); + } + } + + // The ordinary failure, where the swap gives up before touching either file: reported, with the + // source as it was and nothing left beside it + [Test] + public async Task AReplaceThatFailsCleanlyLeavesTheSourceAsItWas() + { + var directory = NewDirectory(); + try + { + var path = Path.Combine(directory, "Sample.cs"); + await File.WriteAllTextAsync(path, "original"); + + Assert.Throws( + () => InlineApplier.WriteThroughTemporary( + path, + Encoding.UTF8.GetBytes("patched"), + (_, _) => throw new IOException("The process cannot access the file."))); + + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo("original"); + await Assert.That(Directory.GetFileSystemEntries(directory)).IsEquivalentTo([path]); + } + finally + { + Directory.Delete(directory, true); + } + } + // Writing in place truncates first, so a reader - or a process that stops partway, which is // the case this stands in for - could see a file with its tail missing. Reading alongside the // apply can only fail when that window is real, so it never goes red on timing alone diff --git a/src/DiffEngine/Inline/InlineApplier.cs b/src/DiffEngine/Inline/InlineApplier.cs index 0972b2a3..3e02cfaf 100644 --- a/src/DiffEngine/Inline/InlineApplier.cs +++ b/src/DiffEngine/Inline/InlineApplier.cs @@ -288,7 +288,15 @@ static void CopyMode(string destination, string temporary) } #endif - static void WriteThroughTemporary(string fullPath, byte[] output) + static void WriteThroughTemporary(string fullPath, byte[] output) => + WriteThroughTemporary(fullPath, output, static (temporary, destination) => File.Replace(temporary, destination, null)); + + /// + /// The swap. Supplied by the tests, because the failure it has to survive is one ReplaceFile + /// produces on its own schedule - an antivirus or sync client holding the file it was just + /// handed - and cannot be arranged on demand. + /// + internal static void WriteThroughTemporary(string fullPath, byte[] output, Action replace) { var directory = Path.GetDirectoryName(fullPath)!; // Named after the file it replaces, so anything left by a process that died between the @@ -300,15 +308,33 @@ static void WriteThroughTemporary(string fullPath, byte[] output) #if NET7_0_OR_GREATER CopyMode(fullPath, temporary); #endif - File.Replace(temporary, fullPath, null); + try + { + replace(temporary, fullPath); + } + catch (Exception exception) + when (!File.Exists(fullPath) && + File.Exists(temporary)) + { + // ReplaceFile can fail after it has already taken the destination away: with no + // backup name, ERROR_UNABLE_TO_MOVE_REPLACEMENT means the original no longer + // exists and the replacement is still under its temporary name. The temporary is + // then the only copy of the source anywhere, and the finally below used to delete + // it. It is the whole patched file, so finishing the swap by hand is the write + // having happened. + MoveIntoPlace(temporary, fullPath, exception); + } } finally { // Replace consumed it. Anything still there is this method's litter, and failing an - // applied patch over a temporary file that could not be deleted helps nobody + // applied patch over a temporary file that could not be deleted helps nobody - unless + // the destination is gone, when it is the source file and is left for the reader of + // the failure to find try { - if (File.Exists(temporary)) + if (File.Exists(temporary) && + File.Exists(fullPath)) { File.Delete(temporary); } @@ -320,6 +346,23 @@ static void WriteThroughTemporary(string fullPath, byte[] output) } } + static void MoveIntoPlace(string temporary, string fullPath, Exception replaceFailure) + { + try + { + File.Move(temporary, fullPath); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + // Named, because this is the one failure a person has to act on: the file they edit is + // not where it was, and this is where it went + throw new IOException( + $"Replacing {fullPath} failed after the original had been removed, and the patched source could not be moved back into place. It is in {temporary}.", + new AggregateException(replaceFailure, exception)); + } + } + /// /// The encoding to read and write the file with. Every one of them throws rather than /// substituting: the applier rewrites the whole file, not just the patched span, so a From 352db433929088dc86a84042c753b9dc573d5027 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 10:01:39 +1000 Subject: [PATCH 03/10] Report an applied patch as applied rather than patching a sibling A patch can reach the patcher again after it has been applied: a second target framework's identical patch arriving after the first was accepted, or each framework's test process applying the same Remove. The anchor has gone from the call it named by then, so the content search looked for it elsewhere, and a sibling holding the same literal - ordinary for a member verifying two values that serialise alike - is exactly where it found it. A Set rewrote the sibling and a Remove stripped its Snapshot call, and both reported Applied. A Set now stops at the recorded line once the call there already holds the new content, and reports AlreadyApplied. A Remove reports AlreadyApplied when the recorded line holds no Snapshot call and the verify statement it belongs to has none chained onto it - including a Snapshot call that had a line of its own, whose removal leaves the rest of its statement ending on the line above. The Reapplying tests apply each shape twice; the Set and the Remove were reproduced against the old code first. RemoveWithNoSnapshotCall expected NotFound for a verify call with no Snapshot, which is exactly what the second Remove finds, so it is now RemoveWithNoSnapshotCallIsAlreadyDone, and RemoveWithNoCallAtAll keeps NotFound for a line with no call on it. --- src/DiffEngine.Tests/InlinePatcherTests.cs | 86 +++++++++++++++- src/DiffEngine/Inline/InlinePatcher.cs | 111 ++++++++++++++++++++- 2 files changed, 193 insertions(+), 4 deletions(-) diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 6f92ec5a..ff3a2bfc 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -1155,17 +1155,101 @@ public async Task RemoveWhenTheCallIsNotChained() await Assert.That(reason).Contains("not a chained call"); } + /// + /// A verify call with no Snapshot left is what a Remove leaves behind, and what the same + /// Remove finds when a second framework's test process applies it. It is done, and saying so + /// is what stops the search going on to strip a Snapshot call from somewhere else. + /// [Test] - public async Task RemoveWithNoSnapshotCall() + public async Task RemoveWithNoSnapshotCallIsAlreadyDone() { var source = Method(" await Verify(value);"); + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out _); + + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + /// + /// Nothing at the recorded line at all, and nothing anywhere else either, is still reported. + /// + [Test] + public async Task RemoveWithNoCallAtAll() + { + var source = Method(" var value = 1;"); + var status = TryApply(source, 5, InlinePatchMode.Remove, null, "", out _, out var reason); await Assert.That(status).IsEqualTo(PatchStatus.NotFound); await Assert.That(reason).Contains("Could not find a Snapshot call"); } + const string twoIdenticalSnapshots = + "class Tests\n{\n async Task Test()\n {\n await Verify(a).Snapshot(\"dup\");\n await Verify(b).Snapshot(\"dup\");\n }\n}\n"; + + /// + /// A patch applied a second time - a second framework's identical patch reaching the queue + /// after the first was accepted. The anchor has gone from the call it named, and the content + /// search used to find it in the sibling instead and rewrite that one. + /// + [Test] + public async Task ReapplyingASetLeavesASiblingWithTheSameLiteral() + { + TryApply(twoIdenticalSnapshots, 6, InlinePatchMode.Set, "\"dup\"", "new", out var once, out _, memberName: "Test"); + await Assert.That(once).Contains("Verify(a).Snapshot(\"dup\")"); + + var status = TryApply(once, 6, InlinePatchMode.Set, "\"dup\"", "new", out _, out _, memberName: "Test"); + + // Done, so the caller writes nothing and the sibling keeps its literal + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + /// + [Test] + public async Task ReapplyingASetByValueLeavesASiblingWithTheSameLiteral() + { + TryApply(twoIdenticalSnapshots, 6, InlinePatchMode.Set, null, "new", out var once, out _, originalValue: "dup", memberName: "Test"); + await Assert.That(once).Contains("Verify(a).Snapshot(\"dup\")"); + + var status = TryApply(once, 6, InlinePatchMode.Set, null, "new", out _, out _, originalValue: "dup", memberName: "Test"); + + // Done, so the caller writes nothing and the sibling keeps its literal + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + /// + /// The same for a Remove, which each framework's test process applies itself: the second one + /// stripped the sibling's Snapshot call. + /// + [Test] + public async Task ReapplyingARemoveLeavesASiblingWithTheSameLiteral() + { + TryApply(twoIdenticalSnapshots, 6, InlinePatchMode.Remove, "\"dup\"", "", out var once, out _, memberName: "Test"); + await Assert.That(once).Contains("Verify(a).Snapshot(\"dup\")"); + + var status = TryApply(once, 6, InlinePatchMode.Remove, "\"dup\"", "", out _, out _, memberName: "Test"); + + // Done, so the caller writes nothing and the sibling keeps its literal + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + + /// + /// A Snapshot call on a line of its own leaves the recorded line holding whatever followed it + /// once it is removed, so the statement it named ends on the line above. + /// + [Test] + public async Task ReapplyingARemoveOfAChainedLineLeavesASiblingWithTheSameLiteral() + { + var source = Method(" await Verify(a).Snapshot(\"dup\");\n await Verify(b)\n .Snapshot(\"dup\");"); + TryApply(source, 7, InlinePatchMode.Remove, "\"dup\"", "", out var once, out _, memberName: "Test"); + await Assert.That(once).Contains("await Verify(b);"); + + var status = TryApply(once, 7, InlinePatchMode.Remove, "\"dup\"", "", out _, out _, memberName: "Test"); + + // Done, so the caller writes nothing and the sibling keeps its literal + await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied); + } + [Test] public async Task TabIndentedFileUsesTabUnit() { diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 0ef27c84..f5986d46 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -124,7 +124,7 @@ public static PatchStatus TryApply( if (mode == InlinePatchMode.Remove) { - return TryRemove(language, source, scan, lineStarts, lineHint, memberLine, originalExpression, originalValue, eol, ref newSource, ref failReason); + return TryRemove(language, source, scan, lineStarts, lineHint, memberLine, EntryPoints(entryPoints), originalExpression, originalValue, eol, ref newSource, ref failReason); } var fileUnit = DetectIndentUnit(source, scan, lineStarts); @@ -144,11 +144,20 @@ public static PatchStatus TryApply( // still unaccepted. // ReSharper disable once RedundantSuppressNullableWarningExpression var needle = NormalizeTo(originalExpression!, eol); - foreach (var (_, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, snapshotName, false)) + var appliedAtHint = false; + foreach (var (nameStart, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, snapshotName, false)) { + var onHint = IsOnHint(lineStarts, nameStart, lineHint); + if (appliedAtHint && + !onHint) + { + return PatchStatus.AlreadyApplied; + } + if (!TryReadArguments(source, scan, openParen, out var expected) || !expected.Matches(source, needle)) { + appliedAtHint |= onHint && HoldsContent(source, scan, openParen, newContent); continue; } @@ -174,8 +183,16 @@ public static PatchStatus TryApply( // the same outcome when nothing matches: report, rather than rewrite whichever call // the hint happens to land on. var previous = SourceLanguage.NormalizeNewlines(originalValue); - foreach (var (_, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, snapshotName, false)) + var appliedAtHint = false; + foreach (var (nameStart, openParen) in FindCalls(source, scan, lineStarts, lineHint, memberLine, snapshotName, false)) { + var onHint = IsOnHint(lineStarts, nameStart, lineHint); + if (appliedAtHint && + !onHint) + { + return PatchStatus.AlreadyApplied; + } + if (!TryReadArguments(source, scan, openParen, out var expected) || expected.IsAbsent || expected.BlockedByName) @@ -187,6 +204,7 @@ public static PatchStatus TryApply( if (!language.TryParse(argument, out var value) || value != previous) { + appliedAtHint |= onHint && value == newContent; continue; } @@ -206,6 +224,23 @@ public static PatchStatus TryApply( return InsertOrCheck(source, scan, lineStarts, lineHint, memberLine, newContent, eol, fileUnit, alreadyOnly: false, ref newSource, ref failReason); } + /// + /// Whether a call is on the recorded line, which yields before anything + /// else and never again. + /// + /// It matters to the content search above because a patch can arrive a second time after it + /// has been applied: a second target framework's identical patch reaching the queue after the + /// first was accepted, or each framework's test process applying the same Remove. The anchor + /// has gone from the call it named by then, so the search went looking for it elsewhere - and a + /// sibling holding the same literal, which is ordinary for a member verifying two values that + /// serialise alike, is exactly where it found it, and rewrote that one. So once the call at + /// the recorded line turns out to already hold what the patch would write, the search stops + /// there and reports it done, rather than carrying on to the next call that matches. + /// + /// + static bool IsOnHint(List lineStarts, int nameStart, int lineHint) => + LineOf(lineStarts, nameStart) == Clamp(lineHint, lineStarts.Count); + static PatchStatus InsertOrCheck( string source, SourceScan scan, @@ -502,12 +537,18 @@ static PatchStatus TryRemove( List lineStarts, int lineHint, int? memberLine, + string[] entryPoints, string? originalExpression, string? originalValue, string eol, ref string newSource, ref string failReason) { + if (RemovedAtHint(source, scan, lineStarts, lineHint, memberLine, entryPoints)) + { + return PatchStatus.AlreadyApplied; + } + var anchored = !string.IsNullOrEmpty(originalExpression) || originalValue != null; if (!TryFindAnchoredCall(language, source, scan, lineStarts, lineHint, memberLine, originalExpression, originalValue, eol, out var nameStart, out var openParen)) { @@ -566,6 +607,70 @@ static PatchStatus TryRemove( return PatchStatus.Applied; } + /// + /// Whether the Snapshot call the recorded line names has already been removed: the line holds + /// no Snapshot call, and the verify statement it belongs to has none chained onto it. + /// + /// A Remove is applied by the test process itself rather than queued, so a multi-targeted run + /// applies the same one once per framework. Every one after the first found the anchor gone + /// from the call it named and went looking for it elsewhere, and a sibling holding the same + /// literal is exactly where it found it: that snapshot was stripped instead, the way + /// describes for a Set. + /// + /// + /// The statement is the nearest verify call at or above the line, provided its chain still + /// reaches the line or the one above it. Removing a Snapshot call that had a line of its own + /// pulls the rest of its statement up onto the line above, so the recorded line then holds + /// whatever followed, and the statement it named ends just before it. + /// + /// + static bool RemovedAtHint(string source, SourceScan scan, List lineStarts, int lineHint, int? memberLine, string[] entryPoints) + { + var lineCount = lineStarts.Count; + if (lineHint < 1 || + lineHint > lineCount) + { + return false; + } + + var floor = memberLine is null ? 1 : Clamp(memberLine.Value, lineCount); + var ceiling = memberLine is null ? lineCount + 1 : NextMemberLine(source, scan, lineStarts, floor); + // A hint outside the member has gone stale, and names nothing + if (lineHint < floor || + lineHint >= ceiling) + { + return false; + } + + // A Snapshot call still on the line is one to remove, whatever it hangs off + if (CallsOnLine(source, scan, lineStarts, lineHint, snapshotName, false).Any()) + { + return false; + } + + for (var line = lineHint; line >= floor; line--) + { + var calls = CallsOnLine(source, scan, lineStarts, line, entryPoints, true).ToList(); + if (calls.Count == 0) + { + continue; + } + + // The last on the line is the nearest one above the hint + var (_, openParen) = calls[calls.Count - 1]; + if (!TryScanArguments(source, scan, openParen, out var closeParen, out _)) + { + return false; + } + + var end = WalkChain(source, scan, closeParen + 1, methodName, out var chained); + return chained < 0 && + LineOf(lineStarts, end - 1) >= lineHint - 1; + } + + return false; + } + /// /// Walks the calls chained onto an invocation and returns where a call should be appended: /// the end of the chain, or the point in front of the language's From 5e4aa3dce7d001e412a097e26dfe4f4ac7026a96 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 10:01:39 +1000 Subject: [PATCH 04/10] Build the Linux viewer binaries against glibc 2.28 The Linux binaries were built on the ubuntu-24.04 runners with nothing setting a glibc floor, and a library records the glibc symbol versions of the headers it was compiled against. The committed ones import __isoc23_sscanf, fmod and fmodf at GLIBC_2.38, so the loader refuses them on Ubuntu 22.04, Debian 12, and RHEL 8 and 9: the viewer cannot open its window there. The Linux jobs now build inside quay.io/pypa/manylinux_2_28, whose glibc 2.28 is the floor .NET 10 itself supports (RHEL 8), through native/build-linux.sh so the same build can be run locally under docker. A new step fails the job if objdump shows the library needing any glibc version above that floor. Stripping moved into the container, which owns the build directory. The committed binaries are unchanged here. This workflow rebuilds them and proposes them in a pull request of their own. --- .github/workflows/build-native.yml | 36 +++++++++++++++++------------- native/build-linux.sh | 30 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) create mode 100755 native/build-linux.sh diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index ced50cb6..a1afb5e6 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -47,22 +47,28 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install build dependencies + # In a manylinux_2_28 container rather than on the runner, for its glibc 2.28: the floor + # .NET 10 itself supports, which is RHEL 8. A library records the glibc symbol versions of + # the headers it was compiled against, and the loader refuses it anywhere older - built on + # the runner's own Ubuntu 24.04 it needed GLIBC_2.38, so the viewer could not start on + # Ubuntu 22.04, Debian 12, or RHEL 8 or 9, and every inline snapshot sent to it was lost. + - name: Build if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - cmake ninja-build \ - libx11-dev libxrandr-dev libxi-dev libxcursor-dev libxinerama-dev \ - libgl1-mesa-dev libglu1-mesa-dev libwayland-dev libxkbcommon-dev - - - name: Configure - if: matrix.rid != 'osx' - run: cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + run: docker run --rm -v "$PWD:/src" -w /src "quay.io/pypa/manylinux_2_28_$(uname -m)" bash native/build-linux.sh - - name: Build - if: matrix.rid != 'osx' - run: cmake --build build --config Release + # The floor the container is there to hold, checked rather than trusted, since nothing else + # would notice until a user on an older distribution did. + - name: Check glibc floor + if: runner.os == 'Linux' + env: + GLIBC_FLOOR: GLIBC_2.28 + run: | + highest=$(objdump -T build/libdiffengine_viewer.so | grep -o 'GLIBC_[0-9][0-9.]*' | sort -uV | tail -n 1) + echo "Highest glibc symbol version required: $highest" + if [ "$(printf '%s\n' "$GLIBC_FLOOR" "$highest" | sort -V | tail -n 1)" != "$GLIBC_FLOOR" ]; then + echo "::error::libdiffengine_viewer.so requires $highest, above the $GLIBC_FLOOR floor" + exit 1 + fi # macOS draws with AppKit and Core Text rather than raylib and ImGui, so it is a Swift # package rather than a CMake project. Both --arch flags in one invocation produce a @@ -85,7 +91,7 @@ jobs: } case "${{ matrix.rid }}" in linux-*) - strip build/libdiffengine_viewer.so + # Already stripped, inside the container that owns the build directory collect DiffEngineViewer.Linux "${{ matrix.rid }}" build/libdiffengine_viewer.so ;; osx) diff --git a/native/build-linux.sh b/native/build-linux.sh new file mode 100755 index 00000000..0b2cb920 --- /dev/null +++ b/native/build-linux.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Builds build/libdiffengine_viewer.so inside a manylinux_2_28 container, whose glibc 2.28 is the +# floor the library then needs - see the Linux build step in .github/workflows/build-native.yml. +# Runs from the repository root: +# +# docker run --rm -v "$PWD:/src" -w /src "quay.io/pypa/manylinux_2_28_$(uname -m)" bash native/build-linux.sh +set -euo pipefail + +# What raylib's bundled GLFW builds against, for both its X11 and Wayland backends: the set the +# runner used to install, under this distribution's names. +dnf install -y \ + git pkgconfig \ + libX11-devel libXext-devel libXrandr-devel libXi-devel libXcursor-devel libXinerama-devel \ + mesa-libGL-devel wayland-devel libxkbcommon-devel + +# native/CMakeLists.txt needs CMake 3.24, newer than the distribution's own. The image carries +# current releases through pipx, so these are only installed where it does not. +export PATH="$HOME/.local/bin:$PATH" +for tool in cmake ninja; do + if ! command -v "$tool" > /dev/null 2>&1; then + pipx install "$tool" + fi +done + +cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release + +# Here rather than in the workflow's collect step: the build directory belongs to this +# container's root, and the runner cannot rewrite what is in it. +strip build/libdiffengine_viewer.so From 37bef37bcd85e8f533d32416f77a6cefceaa2a62 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 10:01:39 +1000 Subject: [PATCH 05/10] Keep what a viewer was handed when it cannot show it An owning viewer could lose a snapshot or a pair it had already told its sender it held. A window that would not open. The viewer binds the port before asking for its window, so its launcher saw an owner and reported the patch queued, but Run returned before PersistOwned and the patch existed nowhere. A native library that will not load and a Linux session with no display both do this, for every inline snapshot of a run. Run now persists on that path, and in a finally when the loop throws. RunInline stages its patch when handing it to an existing owner is refused, which it used to read as a hand over. And ViewerLauncher starts no viewer on Linux with neither DISPLAY nor WAYLAND_DISPLAY set, so the caller hears NoViewerFound and stages the snapshot itself. An arrival while the queue emptied. A settle that empties the queue sets Exit, and the loop acts on it a frame later. EnqueueInline and EnqueueTracked carried Exit across, so a patch or a pair arriving in between was answered and then left with the window. Enqueueing now clears Exit, the loop commits to leaving under the host's lock (CommitExit sets SessionState.Closing), and every other way out of the loop marks it closing too. A closing viewer refuses Inline, Diff, Move and Delete, so the sender stages or reports instead of believing the work queued. TrackMove and TrackDelete also read their files before taking the lock, as their comment already said they did. ViewerProgramTests hands Run a window that will not open and one that throws, and finds the patch staged both times. ViewerSessionTests pins the Exit and Closing transitions, IpcTests a closing viewer refusing each verb, and ViewerLauncherTests the display check. --- src/DiffEngine.Tests/ViewerLauncherTests.cs | 47 +++++++++++ src/DiffEngine/Viewer/ViewerLauncher.cs | 20 +++++ src/DiffEngineViewer.Tests/IpcTests.cs | 21 +++++ .../ViewerProgramTests.cs | 79 +++++++++++++++++++ .../ViewerSessionTests.cs | 50 ++++++++++++ src/DiffEngineViewer/Ipc/MessageHandler.cs | 37 +++++++-- src/DiffEngineViewer/SessionState.cs | 11 +++ src/DiffEngineViewer/ViewerProgram.cs | 78 ++++++++++++------ src/DiffEngineViewer/ViewerSession.cs | 37 ++++++++- 9 files changed, 346 insertions(+), 34 deletions(-) create mode 100644 src/DiffEngine.Tests/ViewerLauncherTests.cs diff --git a/src/DiffEngine.Tests/ViewerLauncherTests.cs b/src/DiffEngine.Tests/ViewerLauncherTests.cs new file mode 100644 index 00000000..295dc1f4 --- /dev/null +++ b/src/DiffEngine.Tests/ViewerLauncherTests.cs @@ -0,0 +1,47 @@ +/// +/// A viewer started with nowhere to draw bound the port, failed to open its window and exited, and +/// the bind read to its launcher as a viewer that had taken the snapshot. On Linux with no display +/// none is started, so the caller hears that no viewer was found and keeps what it sent. +/// +public class ViewerLauncherTests +{ + [Test] + public async Task LinuxWithNoDisplayStartsNoViewer() => + await Assert.That(ViewerLauncher.HasDisplay(linux: true, Variables())).IsFalse(); + + [Test] + public async Task LinuxWithAnXDisplayStartsOne() => + await Assert.That(ViewerLauncher.HasDisplay(linux: true, Variables(("DISPLAY", ":0")))).IsTrue(); + + [Test] + public async Task LinuxWithAWaylandDisplayStartsOne() => + await Assert.That(ViewerLauncher.HasDisplay(linux: true, Variables(("WAYLAND_DISPLAY", "wayland-0")))).IsTrue(); + + /// + /// Set but empty is how a shell unsets a variable it cannot remove, and names no display. + /// + [Test] + public async Task AnEmptyDisplayIsNone() => + await Assert.That(ViewerLauncher.HasDisplay(linux: true, Variables(("DISPLAY", "")))).IsFalse(); + + [Test] + public async Task OtherPlatformsAreTakenToHaveADesktop() => + await Assert.That(ViewerLauncher.HasDisplay(linux: false, Variables())).IsTrue(); + + static Func Variables(params (string Name, string Value)[] set) + { + var variables = set.ToDictionary(_ => _.Name, _ => _.Value); + + string? Read(string name) + { + if (variables.TryGetValue(name, out var value)) + { + return value; + } + + return null; + } + + return Read; + } +} diff --git a/src/DiffEngine/Viewer/ViewerLauncher.cs b/src/DiffEngine/Viewer/ViewerLauncher.cs index f09a455b..7dc5b206 100644 --- a/src/DiffEngine/Viewer/ViewerLauncher.cs +++ b/src/DiffEngine/Viewer/ViewerLauncher.cs @@ -87,6 +87,15 @@ public static string DiffArguments(string temp, string target) => static Process? Start(string arguments, bool stdin = false) { + // With nowhere to draw, a viewer binds the port, fails to open its window and exits, and + // to whoever launched it the bind reads as a viewer that took the work. Not starting one + // tells the caller no viewer was found instead, which is the answer that has it keep what + // it sent. + if (!HasDisplay(RuntimeInformation.IsOSPlatform(OSPlatform.Linux), Environment.GetEnvironmentVariable)) + { + return null; + } + if (!DiffTools.TryFindByName(DiffTool.DiffEngineViewer, out var tool)) { return null; @@ -111,4 +120,15 @@ public static string DiffArguments(string temp, string target) => return null; } } + + /// + /// Whether a window started from this process has anywhere to go. Only Linux can be asked: an + /// SSH session or a container has neither variable, while a desktop session, a forwarded X + /// connection and WSLg each set one. Windows and macOS are taken to have a desktop, since + /// nothing in the environment says otherwise. + /// + internal static bool HasDisplay(bool linux, Func variable) => + !linux || + !string.IsNullOrEmpty(variable("DISPLAY")) || + !string.IsNullOrEmpty(variable("WAYLAND_DISPLAY")); } diff --git a/src/DiffEngineViewer.Tests/IpcTests.cs b/src/DiffEngineViewer.Tests/IpcTests.cs index ccc8e8dd..d67a607a 100644 --- a/src/DiffEngineViewer.Tests/IpcTests.cs +++ b/src/DiffEngineViewer.Tests/IpcTests.cs @@ -48,6 +48,27 @@ public async Task InlineWithoutABodyIsRejected() await Assert.That(response.Ok).IsFalse(); } + /// + /// A viewer on its way out refuses rather than answering. It used to acknowledge a patch or a + /// pair and then exit with it, so the sender believed it queued and staged nothing, and a + /// pending file was in no window and no tray. + /// + [Test] + public async Task AClosingViewerRefusesWhatWouldJoinTheQueue() + { + using var fixture = new ServerFixture(); + fixture.Host.Mutate(_ => _ with { Closing = true }); + + var inline = fixture.Send(Inline(Fixtures.Patch())); + var diff = fixture.Send(new(ViewerVerb.Diff, "temp/sample.received.txt", "code/sample.verified.txt")); + var delete = fixture.Send(new(ViewerVerb.Delete, "code/extra.verified.txt")); + + await Assert.That(inline.Ok).IsFalse(); + await Assert.That(diff.Ok).IsFalse(); + await Assert.That(delete.Ok).IsFalse(); + await Assert.That(fixture.Host.State.Queue).IsEmpty(); + } + [Test] public async Task SettleDropsTheEntry() { diff --git a/src/DiffEngineViewer.Tests/ViewerProgramTests.cs b/src/DiffEngineViewer.Tests/ViewerProgramTests.cs index 040652f3..64584d3f 100644 --- a/src/DiffEngineViewer.Tests/ViewerProgramTests.cs +++ b/src/DiffEngineViewer.Tests/ViewerProgramTests.cs @@ -35,6 +35,85 @@ public async Task AnAttachedViewerPersistsNothing() await Assert.That(project.StagedFiles()).IsEmpty(); } + /// + /// The port is bound before the window is asked for, so whoever launched the viewer was told + /// what it sent had been taken. A window that could not open - no display, a native library + /// that would not load - returned before anything was written, and the snapshot existed + /// nowhere. + /// + [Test] + public async Task AViewerWithNoWindowStillStagesWhatItHolds() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + var state = Fixtures.Inline(Fixtures.Patch(source: source, framework: "net10.0")); + + var code = ViewerProgram.Run(new(state), server: null, link: null, NoWindow); + + await Assert.That(code).IsEqualTo(4); + await Assert.That(project.StagedFiles().Count(_ => _.EndsWith(".inlinepatch"))).IsEqualTo(1); + } + + /// + /// A loop that throws ends the way one that returns does. The throw used to unwind straight to + /// Main's catch, past the persist, and the queue went with the process. + /// + [Test] + public async Task AViewerWhoseLoopThrowsStillStagesWhatItHolds() + { + using var project = new TempProject(); + var source = project.Source("SampleTests.cs"); + var state = Fixtures.Inline(Fixtures.Patch(source: source, framework: "net10.0")); + + Assert.Throws( + () => + { + ViewerProgram.Run(new(state), server: null, link: null, ThrowingWindow.Open); + }); + + await Assert.That(project.StagedFiles().Count(_ => _.EndsWith(".inlinepatch"))).IsEqualTo(1); + } + + static IViewerWindow? NoWindow(string title, int width, int height, bool hidden, out string? error) + { + error = "No display."; + return null; + } + + sealed class ThrowingWindow : IViewerWindow + { + public static IViewerWindow? Open(string title, int width, int height, bool hidden, out string? error) + { + error = null; + return new ThrowingWindow(); + } + + public bool Present(Screen screen) => + throw new InvalidOperationException("The renderer failed."); + + public ViewerInput Poll() => + default; + + public void SetHidden(bool hidden) + { + } + + public void Focus() + { + } + + public void SetClipboard(string text) + { + } + + public bool Capture(Screen screen, int width, int height, string pngPath) => + false; + + public void Dispose() + { + } + } + sealed class TempProject : IDisposable { readonly string directory = Path.Combine( diff --git a/src/DiffEngineViewer.Tests/ViewerSessionTests.cs b/src/DiffEngineViewer.Tests/ViewerSessionTests.cs index b6e70524..afc37b5c 100644 --- a/src/DiffEngineViewer.Tests/ViewerSessionTests.cs +++ b/src/DiffEngineViewer.Tests/ViewerSessionTests.cs @@ -780,6 +780,56 @@ public async Task AQueueChangeClosesTheMenu() await Assert.That(synced.Menu).IsNull(); } + /// + /// A settle that empties the queue sets Exit, and the loop acts on it a frame later. An arrival + /// in between is a reason to stay: carried across, Exit took the new entry out with the window. + /// + [Test] + public async Task AnArrivalAfterTheQueueEmptiedKeepsTheWindow() + { + var state = Fixtures.Inline(Fixtures.Patch()); + var settled = ViewerSession.Settle(state, state.Queue[0].Key); + await Assert.That(settled.Exit).IsTrue(); + + var inline = ViewerSession.EnqueueInline(settled, Fixtures.Patch("OtherTests.cs", 7)); + var tracked = ViewerSession.EnqueueTracked(settled, Fixtures.Move()); + + await Assert.That(inline.Queue).HasSingleItem(); + await Assert.That(inline.Exit).IsFalse(); + await Assert.That(tracked.Queue).HasSingleItem(); + await Assert.That(tracked.Exit).IsFalse(); + } + + /// + /// Once the loop has committed to leaving, nothing joins the queue: the handler answering the + /// wire sees the state come back unchanged and refuses, rather than acknowledging something + /// that is about to leave with the process. + /// + [Test] + public async Task NothingJoinsAQueueThatHasCommittedToLeaving() + { + var state = Fixtures.Inline(Fixtures.Patch()); + var closing = ViewerSession.CommitExit(ViewerSession.Settle(state, state.Queue[0].Key)); + await Assert.That(closing.Closing).IsTrue(); + + await Assert.That(ViewerSession.EnqueueInline(closing, Fixtures.Patch("OtherTests.cs", 7))).IsSameReferenceAs(closing); + await Assert.That(ViewerSession.EnqueueTracked(closing, Fixtures.Move())).IsSameReferenceAs(closing); + } + + /// + /// Only an Exit that is still standing commits: one an arrival has already cleared leaves the + /// window open. + /// + [Test] + public async Task AnArrivalBeforeTheCommitCancelsIt() + { + var state = Fixtures.Inline(Fixtures.Patch()); + var settled = ViewerSession.Settle(state, state.Queue[0].Key); + var arrived = ViewerSession.EnqueueInline(settled, Fixtures.Patch("OtherTests.cs", 7)); + + await Assert.That(ViewerSession.CommitExit(arrived).Closing).IsFalse(); + } + /// /// Owner-mode operations rebuild the inline queue from the display list, and tracked entries /// must never leak into it. diff --git a/src/DiffEngineViewer/Ipc/MessageHandler.cs b/src/DiffEngineViewer/Ipc/MessageHandler.cs index 11017b39..a4809719 100644 --- a/src/DiffEngineViewer/Ipc/MessageHandler.cs +++ b/src/DiffEngineViewer/Ipc/MessageHandler.cs @@ -25,8 +25,9 @@ int IQueueOwner.Enqueue(InlinePatch patch) // Inline entries only, which is what a tray owner counts. This queue also holds tracked // moves and deletes, so counting all of it had the two owners answering the same verb // with different numbers - var count = host - .Mutate(_ => ViewerSession.EnqueueInline(_, patch)) + var state = host.Mutate(_ => ViewerSession.EnqueueInline(_, patch)); + RefuseWhenClosing(state); + var count = state .Queue .Count(_ => _.Kind == QueueEntryKind.Inline); // Brought forward on the entry that arrived, which is what a tray owner does with one of @@ -42,13 +43,35 @@ void IQueueOwner.Settle(string key, string? origin, string? member) => /// /// The files are read here, on the listener thread, so the session stays IO free — the same - /// seam materializes the tray's tracked files through. + /// seam materializes the tray's tracked files through. Before the lock + /// rather than inside it: building an entry reads both files and diffs them, and the render + /// loop takes the same lock every frame. /// - void IQueueOwner.TrackMove(string temp, string target) => - host.Mutate(_ => ViewerSession.EnqueueTracked(_, TrackedEntry.ForMove(temp, target))); + void IQueueOwner.TrackMove(string temp, string target) + { + var entry = TrackedEntry.ForMove(temp, target); + RefuseWhenClosing(host.Mutate(_ => ViewerSession.EnqueueTracked(_, entry))); + } + + void IQueueOwner.TrackDelete(string file) + { + var entry = TrackedEntry.ForDelete(file); + RefuseWhenClosing(host.Mutate(_ => ViewerSession.EnqueueTracked(_, entry))); + } - void IQueueOwner.TrackDelete(string file) => - host.Mutate(_ => ViewerSession.EnqueueTracked(_, TrackedEntry.ForDelete(file))); + /// + /// Thrown rather than returned, because has no refusal to return for + /// these verbs, and a throwing handler is answered with an error: the sender then stages or + /// relaunches instead of believing a window that is on its way out took what it sent. See + /// . + /// + static void RefuseWhenClosing(SessionState state) + { + if (state.Closing) + { + throw new InvalidOperationException("This viewer is closing and can take nothing more. Send it again once it has gone."); + } + } /// /// With patches, each item carries the payloads it was queued from — every variant of it — diff --git a/src/DiffEngineViewer/SessionState.cs b/src/DiffEngineViewer/SessionState.cs index 6b28ba9b..3fbb486f 100644 --- a/src/DiffEngineViewer/SessionState.cs +++ b/src/DiffEngineViewer/SessionState.cs @@ -24,6 +24,17 @@ record SessionState( /// public bool QuitRequested { get; init; } + /// + /// The loop has committed to leaving, so nothing more may join the queue. Set under the host's + /// lock, which is the point of it: a window leaving because its queue emptied used to read + /// without the lock and keep answering while it went, so a patch or a pair + /// that arrived in between was acknowledged to its sender and then left with the process. + /// Arrivals that land before this is set clear and keep the window; ones + /// that land after are refused, and the sender stages what it had rather than believing it + /// queued. + /// + public bool Closing { get; init; } + /// /// The group headers that are folded, by . /// diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs index 134bc5a2..5959ce89 100644 --- a/src/DiffEngineViewer/ViewerProgram.cs +++ b/src/DiffEngineViewer/ViewerProgram.cs @@ -71,9 +71,14 @@ static int RunInline(OpenWindow open) { // Something else holds the queue, a tray or another viewer, so hand the patch over and // get out of the way. Whichever it is will show it. - var forwarded = ViewerClient.TrySend(new(ViewerVerb.Inline, Body: payload), out _, port); - if (!forwarded) + if (!ViewerClient.TrySend(new(ViewerVerb.Inline, Body: payload), out var response, port) || + !response.Ok) { + // Refused - an owner on its way out, or one too old for the payload - or gone + // between the bind and the send. Whoever launched this was told the patch was + // taken, and a refusal used to be read as a hand over, so it is staged rather + // than dropped: this process is the only place it exists. + InlineStaging.Persist([new PendingInline(patch)]); Console.Error.WriteLine("A viewer holds the port but did not accept the patch."); return 1; } @@ -203,14 +208,21 @@ static int RunFile(ViewerRequest request, OpenWindow open) /// /// A non null means this window is displaying someone else's queue, so - /// commands that change it are forwarded rather than applied here. + /// commands that change it are forwarded rather than applied here. Internal so + /// ViewerProgramTests can hand it a window that will not open, or one that throws. /// - static int Run(SessionHost host, ViewerServer? server, OwnerLink? link, OpenWindow open) + internal static int Run(SessionHost host, ViewerServer? server, OwnerLink? link, OpenWindow open) { var window = open("DiffEngineViewer", 1100, 700, false, out var error); if (window is null) { Console.Error.WriteLine(error); + // The port was bound before the window was asked for, so whoever launched this saw an + // owner and was told what it sent had been taken - and that is only in this process's + // memory. Staged instead, where accept tooling finds it. A display that is not there + // or a native library that will not load are both ordinary on Linux, and each used to + // cost every inline snapshot of the run. + PersistOwned(host.State, link); return 4; } @@ -232,30 +244,41 @@ static int Run(SessionHost host, ViewerServer? server, OwnerLink? link, OpenWind ? null : Task.Run(() => new TrackedWatch(host).Run(cancel.Token), Cancel.None); - using (window) - { - Loop(host, window, link, windowCommands, runner); - } - - // Closing the window mid batch does not abandon it: clicking Accept all and then closing - // used to mean both happened, because the click held the window until it was done. Before - // the listener stops, so a drive the tray started finishes answering it. - runner?.Finish(); - - cancel.Cancel(); + // Finally, so a loop that throws still ends the way one that returns does. The throw used + // to unwind straight past all of this to Main's catch, and the queue went with it. try { - listening?.Wait(TimeSpan.FromSeconds(2)); - polling?.Wait(TimeSpan.FromSeconds(2)); - watching?.Wait(TimeSpan.FromSeconds(2)); + using (window) + { + Loop(host, window, link, windowCommands, runner); + } } - catch (AggregateException) + finally { - // Cancellation unwinds through both; nothing to report. - } + // However the loop ended, nothing arriving from here on has a window to be shown in, + // and the listener keeps answering until it is cancelled below + host.Mutate(_ => _ with { Closing = true }); - // After the listener has stopped, so what is written is the final queue. - PersistOwned(host.State, link); + // Closing the window mid batch does not abandon it: clicking Accept all and then + // closing used to mean both happened, because the click held the window until it was + // done. Before the listener stops, so a drive the tray started finishes answering it. + runner?.Finish(); + + cancel.Cancel(); + try + { + listening?.Wait(TimeSpan.FromSeconds(2)); + polling?.Wait(TimeSpan.FromSeconds(2)); + watching?.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException) + { + // Cancellation unwinds through both; nothing to report. + } + + // After the listener has stopped, so what is written is the final queue. + PersistOwned(host.State, link); + } return 0; } @@ -306,12 +329,17 @@ static void Loop( window.SetHidden(command == WindowCommand.Hide); } - var state = host.State; - if (state.Exit) + // Committed under the lock rather than read and acted on. Between reading Exit and the + // listener stopping, an arrival used to be answered as queued and then leave with the + // window. One landing first clears Exit and keeps the window open; one landing after + // finds the viewer closing and is refused, so its sender stages it instead. + if (host.State.Exit && + host.Mutate(ViewerSession.CommitExit).Closing) { return; } + var state = host.State; if (!window.Present(ScreenBuilder.Build(state))) { return; diff --git a/src/DiffEngineViewer/ViewerSession.cs b/src/DiffEngineViewer/ViewerSession.cs index 51cc76fb..13c6a27c 100644 --- a/src/DiffEngineViewer/ViewerSession.cs +++ b/src/DiffEngineViewer/ViewerSession.cs @@ -28,6 +28,13 @@ public static SessionState Resize(SessionState state, int columns, int rows) => /// public static SessionState EnqueueInline(SessionState state, InlinePatch patch) { + // Nothing joins a queue whose window has committed to leaving. Returned as it is, and the + // caller answering the wire refuses when it sees that + if (state.Closing) + { + return state; + } + var key = InlineKey.For(patch.SourceFile, patch.LineHint); var current = state.Current; var queue = Rebuild(state, Pending(state).Enqueue(patch)); @@ -52,7 +59,10 @@ public static SessionState EnqueueInline(SessionState state, InlinePatch patch) Queue = queue, Selected = selected, // The open menu indexes the queue it was opened over, which just changed. - Menu = null + Menu = null, + // Something to show again. A settle that emptied the queue a moment ago set this, and + // carrying it across the arrival took the new entry out with the window + Exit = false }; // Nothing on screen before means nobody has been reading this one yet either. @@ -133,6 +143,12 @@ public static SessionState Settle(SessionState state, string key, string? origin /// public static SessionState EnqueueTracked(SessionState state, QueueEntry entry) { + // As EnqueueInline: refused, by the caller, once the window has committed to leaving + if (state.Closing) + { + return state; + } + var replacedCurrent = state.Current?.Key == entry.Key; var kept = state.Queue.Where(_ => _.Key != entry.Key); var queue = QueueProjection.Order([..kept, entry]); @@ -142,7 +158,9 @@ public static SessionState EnqueueTracked(SessionState state, QueueEntry entry) { Queue = queue, Selected = selected < 0 ? 0 : selected, - Menu = null + Menu = null, + // As EnqueueInline: an arrival is a reason to stay + Exit = false }; if (currentKey is null || @@ -251,6 +269,21 @@ public static SessionState Refresh( return Remove(state, queue, state.Message); } + /// + /// The loop's decision to leave, taken under the host's lock so it cannot cross an arrival: + /// still set means nothing has joined the queue since it + /// emptied, and from here nothing can. See . + /// + public static SessionState CommitExit(SessionState state) + { + if (!state.Exit) + { + return state; + } + + return state with { Closing = true }; + } + /// /// Selects by key rather than index, for a queue owner asking that a particular item be the /// one on screen. A key that is not here leaves the selection alone, because a listing and the From 9d9f6a0ffbc064443e0b5d5bb5cad29db4a11c8c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 23 Sep 2026 10:01:40 +1000 Subject: [PATCH 06/10] Apply tray snapshots before deleting the files they replace A snapshot moving inline arrives as two unrelated entries: the patch that writes its literal, and a delete of the verified file it replaces. The tray's accept-all - the menu, both hot keys, and a displaying viewer's Accept all forwarded to it - ran the deletes first, so a patch refused afterwards (the source edited since the run, or a call site that cannot host a Snapshot) left the snapshot in neither place. The viewer's own batch has always applied snapshots first and held its deletes when one was not written. The tray, which owns the queue in the usual arrangement, did not. Every tray accept-all now applies the snapshots before the deletes, and holds every delete when a snapshot in the batch was not written, saying why. The owned host decides from the batch's tally. A tray driving a viewer's queue reads the refusal back out of the full listing, where a non-conflicted entry with a status is one the batch could not write, and an owner that cannot be asked counts as refused. The owned batch also applied from a copy of the queue taken when it started, so an entry settled, discarded, replaced or made a conflict of while earlier ones were applying was still written into the source. It now takes keys and looks each entry up again just before applying it, the way the viewer's batch claims its entries. Pinned from the menu (TrackerDeleteTest), the wire (OwnedInlineHostTest, including a discard during a held batch), the tracked files (TrackerTrackedFilesTest), and a tray driving an owning viewer (TrayViewerSyncTest). AnAcceptAllCountsTheFilesIntoItsProgress now expects no files done while the first snapshot applies. --- .../OwnedInlineHostTest.cs | 91 ++++++++++++++- src/DiffEngineTray.Tests/StubInlineHost.cs | 15 ++- src/DiffEngineTray.Tests/TrackerDeleteTest.cs | 48 ++++++++ .../TrackerTrackedFilesTest.cs | 25 +++- .../TrayViewerSyncTest.cs | 36 ++++++ src/DiffEngineTray/IInlineHost.cs | 12 +- src/DiffEngineTray/ITrackedFiles.cs | 10 +- src/DiffEngineTray/OwnedInlineHost.cs | 84 +++++++++++--- src/DiffEngineTray/RemoteInlineHost.cs | 27 ++++- src/DiffEngineTray/Tracker.cs | 109 ++++++++++++++---- 10 files changed, 402 insertions(+), 55 deletions(-) diff --git a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs b/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs index 66c3af1f..dfd55124 100644 --- a/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs +++ b/src/DiffEngineTray.Tests/OwnedInlineHostTest.cs @@ -617,8 +617,14 @@ public bool Has(string key) => return (true, "Discarded tracked"); } - public (int accepted, int kept) AcceptAll(Action? advanced = null) + /// + /// What the last sweep was told about its deletes, null before there was one. + /// + public bool? HeldDeletes { get; private set; } + + public (int accepted, int kept) AcceptAll(bool holdDeletes, Action? advanced = null) { + HeldDeletes = holdDeletes; // One step per file the sweep reports, which is what the real tracker calls it for for (var file = 0; file < SweepResult.accepted + SweepResult.kept; file++) { @@ -787,12 +793,91 @@ public async Task AnAcceptAllCountsTheFilesIntoItsProgress() var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); held.WaitUntilHeld(); - await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Progress).IsEqualTo(new AcceptProgress(2, 3)); + // Snapshots first, so none of the files has been dealt with while one is applying, and + // the total still counts them + await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Progress).IsEqualTo(new AcceptProgress(0, 3)); held.Release(); await Assert.That((await accepting).Message).IsEqualTo("Accepted 1, plus 2 files"); } + /// + /// A snapshot moving inline arrives as a patch plus a delete of the verified file it replaces. + /// The files went first, so a patch refused after them had already cost its snapshot the file: + /// in neither place. Now the patches go first, and a refusal holds the deletes. + /// + [Test] + public async Task AnAcceptAllWhosePatchIsRefusedHoldsTheDeletes() + { + using var owner = new Owner(_ => InlineApplyResult.NotFound("The source changed since the test run.")); + var tracked = new FakeTracked + { + DeleteList = [new(@"delete:c:\code\b.verified.txt", "b.verified.txt", null, @"c:\code\b.verified.txt")], + SweepResult = (0, 1) + }; + owner.Host.TrackedFiles = tracked; + owner.Queue(); + + var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)); + + await Assert.That(tracked.HeldDeletes).IsTrue(); + await Assert.That(response.Message).Contains(Tracker.DeletesHeld); + } + + /// + /// Every patch written, nothing to protect: the deletes go ahead. + /// + [Test] + public async Task AnAcceptAllWhosePatchesAllLandCarriesOutTheDeletes() + { + using var owner = new Owner(_ => InlineApplyResult.Applied); + var tracked = new FakeTracked + { + DeleteList = [new(@"delete:c:\code\b.verified.txt", "b.verified.txt", null, @"c:\code\b.verified.txt")], + SweepResult = (1, 0) + }; + owner.Host.TrackedFiles = tracked; + owner.Queue(); + + var response = owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30)); + + await Assert.That(tracked.HeldDeletes).IsFalse(); + await Assert.That(response.Message).IsEqualTo("Accepted 1, plus 1 files"); + } + + /// + /// An accept-all runs for as long as the queue is long, and the queue moves meanwhile. It + /// applied from a copy taken at the start, so an entry discarded while an earlier one was + /// applying was still written into the source. + /// + [Test] + public async Task AnEntryDiscardedDuringAnAcceptAllIsNotWritten() + { + using var held = new HeldApply(1); + var applied = new List(); + using var owner = new Owner( + patch => + { + lock (applied) + { + applied.Add(patch); + } + + return held.Apply(patch); + }); + owner.Queue(line: 1); + owner.Queue(line: 2); + + var accepting = Task.Run(() => owner.Send(new(ViewerVerb.AcceptAll), TimeSpan.FromSeconds(30))); + held.WaitUntilHeld(); + owner.Send(new(ViewerVerb.Discard, InlineKey.For(@"c:\repo\SampleTests.cs", 2))); + held.Release(); + await accepting; + + await Assert.That(applied).HasSingleItem(); + await Assert.That(applied[0].LineHint).IsEqualTo(1); + } + /// /// The menu's accept-all reaches the queue through the tray's own host rather than the wire, /// and a viewer displaying the queue follows that one the same way. @@ -804,7 +889,7 @@ public async Task TheMenusAcceptAllReportsProgressToo() using var owner = new Owner(held.Apply); owner.Queue(); - var accepting = Task.Run(() => owner.Host.AcceptAll(out _)); + var accepting = Task.Run(() => owner.Host.AcceptAll(out _, out _)); held.WaitUntilHeld(); await Assert.That(owner.Send(new(ViewerVerb.ListFull)).Progress).IsEqualTo(new AcceptProgress(0, 1)); diff --git a/src/DiffEngineTray.Tests/StubInlineHost.cs b/src/DiffEngineTray.Tests/StubInlineHost.cs index 852a1cd1..d8793ec4 100644 --- a/src/DiffEngineTray.Tests/StubInlineHost.cs +++ b/src/DiffEngineTray.Tests/StubInlineHost.cs @@ -53,9 +53,22 @@ public bool Discard(PendingSnapshot snapshot, out string? message) public string? AcceptAllMessage { get; init; } - public bool AcceptAll(out string? message) + /// + /// Whether the sweep reports a snapshot it could not write, which is what holds the tray's + /// pending deletes back. + /// + public bool AcceptAllRefuses { get; init; } + + /// + /// Run as the sweep starts, so a test can look at what else has or has not happened by then. + /// + public Action? AcceptingAll { get; init; } + + public bool AcceptAll(out string? message, out bool refused) { + AcceptingAll?.Invoke(); message = AcceptAllMessage; + refused = AcceptAllRefuses; return AcceptAllSucceeds; } diff --git a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs b/src/DiffEngineTray.Tests/TrackerDeleteTest.cs index 8e8a60d9..8e98ab29 100644 --- a/src/DiffEngineTray.Tests/TrackerDeleteTest.cs +++ b/src/DiffEngineTray.Tests/TrackerDeleteTest.cs @@ -122,6 +122,54 @@ public async Task AcceptAllContinuesPastAnUndeletableFile() await Assert.That(tracker.Deletes).HasSingleItem(); } + /// + /// A snapshot moving inline arrives as a patch plus a delete of the verified file it replaces, + /// and "Accept all" used to delete first. The snapshots go first now, while that file is still + /// there to fall back on. + /// + [Test] + public async Task AcceptAllAppliesTheSnapshotsBeforeTheDeletes() + { + bool? existedWhileAccepting = null; + await using var tracker = new RecordingTracker( + inline: new StubInlineHost(new PendingSnapshot(@"c:\repo\sample.cs|12", "Sample.cs:12", null)) + { + AcceptingAll = () => existedWhileAccepting = File.Exists(file1) + }); + tracker.AddDelete(file1); + + await tracker.AcceptAll(); + + await Assert.That(existedWhileAccepting).IsTrue(); + await Assert.That(File.Exists(file1)).IsFalse(); + } + + /// + /// A patch was refused, so the file a delete would remove may be the only copy of that snapshot + /// left. The delete stays pending, the file stays where it is, and the balloon says why. + /// + [Test] + public async Task AcceptAllHoldsTheDeletesWhenASnapshotWasNotWritten() + { + var warnings = new List(); + await using var tracker = new RecordingTracker( + inlineFailed: warnings.Add, + inline: new StubInlineHost(new PendingSnapshot(@"c:\repo\sample.cs|12", "Sample.cs:12", null)) + { + AcceptAllSucceeds = false, + AcceptAllRefuses = true, + AcceptAllMessage = "Accepted 0, 1 not written" + }); + tracker.AddDelete(file1); + + await tracker.AcceptAll(); + + await Assert.That(File.Exists(file1)).IsTrue(); + await Assert.That(tracker.Deletes).HasSingleItem(); + await Assert.That(warnings).IsEquivalentTo( + [$"Could not accept the pending snapshots. Accepted 0, 1 not written {Tracker.DeletesHeld}"]); + } + public void Dispose() { File.Delete(file1); diff --git a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs b/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs index deae12ea..44fb92ac 100644 --- a/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs +++ b/src/DiffEngineTray.Tests/TrackerTrackedFilesTest.cs @@ -153,7 +153,7 @@ public async Task AcceptAllSweepsAndCountsWhatStayed() await File.WriteAllTextAsync(temp, "content"); tracker.AddMove(temp, target, null, null, false, null); - var (accepted, kept) = tracked.AcceptAll(); + var (accepted, kept) = tracked.AcceptAll(holdDeletes: false); await Assert.That(accepted).IsEqualTo(2); await Assert.That(kept).IsEqualTo(0); @@ -161,6 +161,29 @@ public async Task AcceptAllSweepsAndCountsWhatStayed() await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("content"); } + /// + /// A snapshot swept alongside was not written, so the file a delete would remove may be the + /// only copy of it left. The delete stays pending, and the file stays where it is; a move is + /// the snapshot arriving rather than the last copy leaving, so it goes ahead. + /// + [Test] + public async Task AcceptAllHoldingDeletesLeavesThemPending() + { + await using var tracker = new RecordingTracker(); + ITrackedFiles tracked = tracker; + tracker.AddDelete(file); + await File.WriteAllTextAsync(temp, "content"); + tracker.AddMove(temp, target, null, null, false, null); + + var (accepted, kept) = tracked.AcceptAll(holdDeletes: true); + + await Assert.That(accepted).IsEqualTo(1); + await Assert.That(kept).IsEqualTo(1); + await Assert.That(File.Exists(file)).IsTrue(); + await Assert.That(tracker.Deletes).HasSingleItem(); + await Assert.That(await File.ReadAllTextAsync(target)).IsEqualTo("content"); + } + [Test] public async Task DiscardAllUntracksDeletesAndDropsMoveTemps() { diff --git a/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs b/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs index df69ce9d..30c9ee24 100644 --- a/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs +++ b/src/DiffEngineTray.Tests/TrayViewerSyncTest.cs @@ -535,6 +535,42 @@ public async Task TrayAcceptAllReportsWhatTheOwningViewerKept() await Assert.That(pair.Failures.Single()).Contains("the file is locked"); } + /// + /// A snapshot moving inline while a viewer holds the queue sends its patch there and the + /// delete of its verified file here. The tray reads what the batch refused back out of the + /// viewer's listing, and holds its delete rather than removing the file under a patch that + /// was never written. + /// + [Test] + public async Task TrayAcceptAllHoldsItsDeletesWhenTheOwningViewerRefusedAPatch() + { + await using var pair = new ViewerOwned(_ => ViewerSideApplyResult.NotFound("The source changed since the test run.")); + pair.Queue(sample, 1); + var stale = pair.StageStaleFile(); + pair.Tracker.AddDelete(stale); + + await pair.Tracker.AcceptAll(); + + await Assert.That(File.Exists(stale)).IsTrue(); + await Assert.That(pair.Tracker.Deletes).HasSingleItem(); + await Assert.That(pair.Failures.Single()).Contains(Tracker.DeletesHeld); + } + + /// + [Test] + public async Task TrayAcceptAllCarriesOutItsDeletesWhenTheOwningViewerWroteEveryPatch() + { + await using var pair = new ViewerOwned(); + pair.Queue(sample, 1); + var stale = pair.StageStaleFile(); + pair.Tracker.AddDelete(stale); + + await pair.Tracker.AcceptAll(); + + await Assert.That(File.Exists(stale)).IsFalse(); + await Assert.That(pair.Tracker.Deletes).IsEmpty(); + } + /// /// An owning viewer applies inside its session, and InlineApplier waits up to ten seconds on /// its cross process mutex, so an accept legitimately outlasts the wait the listing verbs use. diff --git a/src/DiffEngineTray/IInlineHost.cs b/src/DiffEngineTray/IInlineHost.cs index ca9c5156..c124a8a5 100644 --- a/src/DiffEngineTray/IInlineHost.cs +++ b/src/DiffEngineTray/IInlineHost.cs @@ -25,7 +25,17 @@ interface IInlineHost AcceptOutcome Accept(PendingSnapshot snapshot, out string? message); bool Discard(PendingSnapshot snapshot, out string? message); - bool AcceptAll(out string? message); + + /// + /// True when nothing is pending afterwards. + /// + /// + /// A snapshot this sweep tried was not written, or the owner could not be asked. What holds + /// the tray's pending deletes back, since a snapshot moving inline arrives as a patch plus a + /// delete of the verified file it replaces, and that file may be the only copy of it left. + /// + bool AcceptAll(out string? message, out bool refused); + /// /// False when the queue owner could not be asked, so a caller clearing its own state knows not /// to. diff --git a/src/DiffEngineTray/ITrackedFiles.cs b/src/DiffEngineTray/ITrackedFiles.cs index a296d852..a7ae565e 100644 --- a/src/DiffEngineTray/ITrackedFiles.cs +++ b/src/DiffEngineTray/ITrackedFiles.cs @@ -26,14 +26,18 @@ interface ITrackedFiles (bool ok, string? message) Discard(string key); /// - /// Accept every tracked delete and move without prompting. Kept is what stayed pending — - /// locked moves, undeletable files. + /// Accept every tracked move and delete without prompting. Kept is what stayed pending — + /// locked moves, undeletable files, and deletes held back. /// + /// + /// Leave every delete pending rather than carrying it out, because a snapshot swept alongside + /// was not written, and the file a delete removes may be the only copy of it left. + /// /// /// Called as each file is dealt with, whichever way it went, so the owner can say how far an /// accept-all has got while a locked move is still being retried. /// - (int accepted, int kept) AcceptAll(Action? advanced = null); + (int accepted, int kept) AcceptAll(bool holdDeletes, Action? advanced = null); /// /// Track a pending move or delete that arrived over the viewer port rather than the piper one. diff --git a/src/DiffEngineTray/OwnedInlineHost.cs b/src/DiffEngineTray/OwnedInlineHost.cs index 5a173739..f17df644 100644 --- a/src/DiffEngineTray/OwnedInlineHost.cs +++ b/src/DiffEngineTray/OwnedInlineHost.cs @@ -145,14 +145,14 @@ public bool Discard(PendingSnapshot snapshot, out string? message) } } - public bool AcceptAll(out string? message) + public bool AcceptAll(out string? message, out bool refused) { lock (accepting) { StartProgress(0); try { - message = AcceptEvery(); + message = AcceptEvery(0, out refused); } finally { @@ -357,9 +357,17 @@ bool IQueueOwner.Has(string key) /// /// The wire's accept-all sweeps everything this owner shows a viewer: tracked deletes and - /// moves as well as the snapshots, mirroring the tray menu's own "Accept all". Files first, - /// the order that menu has always used, and never through , - /// whose snapshot half would re-enter this host and whose move path can prompt. + /// moves as well as the snapshots, mirroring the tray menu's own "Accept all". Never through + /// , whose snapshot half would re-enter this host and whose move + /// path can prompt. + /// + /// Snapshots first, and the deletes held when one of them was not written. A snapshot moving + /// inline arrives as two unrelated entries - the patch that writes the literal, and a delete + /// of the verified file it replaces - and files first, the order this used to take, deleted + /// that file before finding out the patch would be refused: the snapshot lost both copies at + /// once. The viewer's own batch has always run this way round, for that reason; this is the + /// same rule for the arrangement where the tray holds the queue, which is the usual one. + /// /// /// The files count towards the progress a listing reports, since a move that is being retried /// while a diff tool lets go of it is as much of the wait as any snapshot. @@ -369,14 +377,17 @@ string IQueueOwner.AcceptAll() { (int accepted, int kept)? tracked; string message; + var held = false; lock (accepting) { - var files = TrackedFiles is { } trackedFiles ? trackedFiles.Moves().Count + trackedFiles.Deletes().Count : 0; - StartProgress(files); + var moves = TrackedFiles?.Moves().Count ?? 0; + var deletes = TrackedFiles?.Deletes().Count ?? 0; + StartProgress(moves + deletes); try { - tracked = TrackedFiles?.AcceptAll(Advance); - message = AcceptEvery(); + message = AcceptEvery(moves + deletes, out var refused); + tracked = TrackedFiles?.AcceptAll(refused, Advance); + held = refused && deletes > 0; } finally { @@ -394,6 +405,11 @@ string IQueueOwner.AcceptAll() var clause = swept.kept == 0 ? $"{swept.accepted} files" : $"{swept.accepted} files ({swept.kept} kept)"; + if (held) + { + return $"{message}, plus {clause}. {Tracker.DeletesHeld}"; + } + return $"{message}, plus {clause}"; } @@ -510,34 +526,63 @@ void IQueueOwner.Window(WindowCommand command, string? key) /// /// Every snapshot pending when it starts, applied outside the gate and completed one at a - /// time. The list is immutable, so applying over it is safe, and each completion skips an - /// entry that changed underneath it. Conflicted entries are never applied: they are counted - /// into the message at the end. + /// time. Conflicted entries are never applied: they are counted into the message at the end. + /// + /// Taken as keys, and each looked up again when its turn comes, rather than applied from a + /// copy of the queue taken at the start. A batch holds an apply per entry, each of which can + /// wait on InlineApplier's mutex, and the queue does not stand still meanwhile: a test that + /// started passing settles its entry, a discard empties the queue, a re-run replaces a patch, + /// a second framework makes a conflict of one. Applying from the copy wrote every one of those + /// into the source regardless - the old failing content over a test that now passed, snapshots + /// just discarded - and then ignored the outcome because the entry had changed. The viewer's + /// batch claims its entries the same way. + /// /// /// Completed as each lands rather than all together at the end, so a displaying viewer's next /// listing shows the queue shrinking and says how far the batch has got. Together they left /// the window showing an untouched queue for as long as the batch took. /// /// - string AcceptEvery() + /// + /// The tracked files the caller sweeps once the snapshots are done, for the progress total. + /// + /// + /// A snapshot in this batch was not written, which is what holds the deletes swept after it. + /// + string AcceptEvery(int files, out bool refused) { - List pending; + List keys; lock (gate) { - pending = queue.Items + keys = queue.Items .Where(_ => !_.Conflicted) + .Select(_ => _.Key) .ToList(); - // Exact now, where the start could only estimate it: the files swept first gave - // snapshots time to arrive or settle + // Exact now, where the start could only estimate it: anything can arrive or settle + // between the two if (progress is not null) { - progress = progress with { Total = progress.Done + pending.Count }; + progress = progress with { Total = progress.Done + keys.Count + files }; } } var tally = new AcceptAllTally(); - foreach (var entry in pending) + foreach (var key in keys) { + PendingInline? entry; + lock (gate) + { + entry = queue.Find(key); + if (entry is null || + entry.Conflicted) + { + // Settled, discarded or made a conflict of since the batch began. Nothing to + // apply, and one fewer to wait for + progress = progress?.Advance(); + continue; + } + } + var result = applier(entry.Patch); // Together, so no listing can show the entry gone and the count not yet moved past it lock (gate) @@ -551,6 +596,7 @@ string AcceptEvery() lock (gate) { + refused = tally.Refused; return tally.Message(queue.Conflicts); } } diff --git a/src/DiffEngineTray/RemoteInlineHost.cs b/src/DiffEngineTray/RemoteInlineHost.cs index 035bf618..092d0757 100644 --- a/src/DiffEngineTray/RemoteInlineHost.cs +++ b/src/DiffEngineTray/RemoteInlineHost.cs @@ -124,10 +124,31 @@ public bool Discard(PendingSnapshot snapshot, out string? message) => /// True only when the queue is empty afterwards, for the reason gives — /// and matching what an owning tray reports, which is also "is anything still pending". A /// conflict counts as not accepted, which is right: it is what a reviewer still has to resolve. + /// + /// Refused is read back the same way, out of the full listing that follows, since the wire + /// carries a message rather than a tally. The owner keeps an entry it could not write and says + /// why on it, while one that arrived during the batch carries nothing and a conflict is never + /// tried - so a non-conflicted entry with a status is one this batch refused. An owner that + /// could not be asked, before or after, counts as refused: what waits on the answer is a + /// delete, and a delete is the one thing not safe to guess about. + /// /// - public bool AcceptAll(out string? message) => - Send(ViewerVerb.AcceptAll, null, acceptAllWait, out message) && - List().Count == 0; + public bool AcceptAll(out string? message, out bool refused) + { + if (!Send(ViewerVerb.AcceptAll, null, acceptAllWait, out message) || + !Exchange(new(ViewerVerb.ListFull), ViewerClient.ShortTimeout, out var response) || + !response.Ok) + { + refused = true; + return false; + } + + // A full listing lists a conflicted entry's other variants, and an entry has them exactly + // when it is conflicted + refused = response.Items.Any(_ => _.Variants.Count == 0 && + _.Status is not null); + return response.Items.Count == 0; + } /// /// As , and the outcome is returned rather than dropped. Discarded on a diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs index 7b20899f..d4a853f8 100644 --- a/src/DiffEngineTray/Tracker.cs +++ b/src/DiffEngineTray/Tracker.cs @@ -345,18 +345,49 @@ public Task AcceptAllSnapshots() => { try { - // Live read, not the scan cache: this can be called before the first scan, and - // acting on a stale empty cache would silently do nothing. Inside the worker - // rather than in front of it, because the caller is a menu click or a hot key and - // the read is a round trip whenever a viewer owns the queue. - if (inline.List().Count == 0) + SweepSnapshots(out var failure); + if (failure is not null) { - return; + inlineFailed?.Invoke(failure); } - if (!inline.AcceptAll(out var message)) + Refresh(); + } + catch (Exception exception) + { + ExceptionHandler.Handle("Failed to accept the pending snapshots", exception); + } + }); + + /// + /// The second half of an accept-all: the snapshots, then the deletes, on a worker for the + /// reason gives. + /// + /// Deletes after the snapshots, and not at all when one of those was not written. A snapshot + /// moving inline arrives as a patch plus a delete of the verified file it replaces, and nothing + /// ties the two together. Deleting first, which is what this used to do, removed that file + /// before finding out the patch would be refused, so the snapshot was in neither place: not in + /// the source, and not on disk. The viewer's own accept-all has always held its deletes this + /// way, for the same reason. + /// + /// + Task AcceptSnapshotsThenDeletes() => + Task.Run(() => + { + try + { + if (!SweepSnapshots(out var failure)) { - inlineFailed?.Invoke($"Could not accept the pending snapshots. {message}"); + AcceptAllDeletes(); + } + else if (!deletes.IsEmpty) + { + failure = failure is null ? DeletesHeld : $"{failure} {DeletesHeld}"; + } + + if (failure is not null) + { + inlineFailed?.Invoke(failure); } Refresh(); @@ -367,6 +398,35 @@ public Task AcceptAllSnapshots() => } }); + /// + /// What a user is told about the deletes an accept-all left pending, from either surface. + /// + public const string DeletesHeld = "Pending deletes were kept, since a snapshot in this batch was not written and a file being deleted may be the only copy of it left. Accept them on their own to delete them anyway."; + + /// + /// Accepts every pending snapshot, and returns whether one it tried was not written. + /// + /// What to tell the user, when something is still pending afterwards. + bool SweepSnapshots(out string? failure) + { + failure = null; + // Live read, not the scan cache: this can be called before the first scan, and acting on + // a stale empty cache would silently do nothing. Inside the worker rather than in front of + // it, because the caller is a menu click or a hot key and the read is a round trip + // whenever a viewer owns the queue. + if (inline.List().Count == 0) + { + return false; + } + + if (!inline.AcceptAll(out var message, out var refused)) + { + failure = $"Could not accept the pending snapshots. {message}"; + } + + return refused; + } + /// /// Accepts just these snapshots, for a group header: unlike , /// solution A's header must not accept solution B's queue. @@ -720,14 +780,14 @@ public void Clear() } /// - /// The returned task covers the snapshot half, which runs on a worker for the reason - /// gives. The menu and the hot keys discard it; tests - /// await it so what the other surface should now be showing is settled rather than in flight. + /// The moves here, on the calling thread, because a locked one can prompt. The returned task + /// covers the rest - the snapshots, then the deletes, which have to wait for them - and runs + /// on a worker for the reason gives. The menu and the + /// hot keys discard it; tests await it so what the other surface should now be showing is + /// settled rather than in flight. /// public Task AcceptOpen() { - AcceptAllDeletes(); - AcceptMoves( moves.Values .Where(_ => _.IsOpen) @@ -735,24 +795,22 @@ public Task AcceptOpen() // Every pending snapshot is open by definition: the viewer only stays running while it // has something to show. - return AcceptAllSnapshots(); + return AcceptSnapshotsThenDeletes(); } /// public Task AcceptAll() { - AcceptAllDeletes(); - AcceptMoves(moves.Values); - return AcceptAllSnapshots(); + return AcceptSnapshotsThenDeletes(); } void AcceptAllDeletes() { // One at a time, and no Clear afterwards: a delete that fails re-tracks itself, and - // clearing would throw that away. Unguarded, the first bad one also took AcceptMoves and - // AcceptAllSnapshots with it, so "Accept all" stopped at the first read-only file + // clearing would throw that away. Unguarded, the first bad one also took the rest of the + // sweep with it, so "Accept all" stopped at the first read-only file foreach (var delete in deletes.Values.ToList()) { Accept(delete); @@ -866,13 +924,13 @@ bool ITrackedFiles.Untrack(string key) return (false, null); } - (int accepted, int kept) ITrackedFiles.AcceptAll(Action? advanced) + (int accepted, int kept) ITrackedFiles.AcceptAll(bool holdDeletes, Action? advanced) { var accepted = 0; var kept = 0; - foreach (var delete in deletes.Values.ToList()) + foreach (var move in moves.Values.ToList()) { - if (AcceptTracked(delete).ok) + if (AcceptWithoutPrompting(move).ok) { accepted++; } @@ -884,9 +942,12 @@ bool ITrackedFiles.Untrack(string key) advanced?.Invoke(); } - foreach (var move in moves.Values.ToList()) + foreach (var delete in deletes.Values.ToList()) { - if (AcceptWithoutPrompting(move).ok) + // Held rather than tried, and left tracked, so it can still be accepted on its own by + // anyone who knows the file is redundant + if (!holdDeletes && + AcceptTracked(delete).ok) { accepted++; } From eb1641ad7cfb4a1b416f3f937a911078fc468bc9 Mon Sep 17 00:00:00 2001 From: SimonCropp <122666+SimonCropp@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:03:56 +0000 Subject: [PATCH 07/10] Rebuild native renderer binaries --- .../native/libdiffengine_viewer.so | Bin 1979912 -> 2243240 bytes .../linux-x64/native/libdiffengine_viewer.so | Bin 2286960 -> 2451624 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so index 79c8d64922ca8f2f3f5f8eac3250d47d50c26861..8f1ffc5f077c988e52dbe7451ed947d37f2c1dcc 100644 GIT binary patch literal 2243240 zcmeF)33waT)j0f#vY>kKtUJ^ zVkuQ9i%MC9OQ{GH1TO_aS%eEz1qv!HRL}ydNkJ?Hu`6u(&gh)6kB>Ep;pP25&-Xl| zc970bckaD&=FXzg#5%uj`7x$3V-)Q%R(U`fiWJ2~ZzK+_(u#mNm5K1*nab|vvf~Ed z8Tle&!`ifGg=$xIe7U%ZW*Yu{ZsYW_YtMW>Y;RYZZ}{_;Ys_WWp80$e;GJw?R{8yG zp=#K(ds#VDESHVN7t~L+SL%HBuW&y5S2&;c9H(w;&)B-6sC`r?C%rG9XTy8yLwi=s zst?b9dYaVN_g%MVIhN0#G?iJ=p0)XY0Q0H+9PPtG1y@pgdU>CnPCvi8s>Wr%WXG_gG%2QwcV1#q{54M;Jz+!S zER!{$n5{NNv0Z=cwD_1YOVUr&rKi+B_e8O|OEGU3Hklq*T(ez&wq07MC_lX`u=Au% zm)F`Rn18y*tHm+D?>lzSF=}7lhp7mY`uGs0kIS#{KcCR+PvQA9m`X5xP4*2utDB06 zk}wXQ$J6Tx@N9-@5=`5Zsk7|>uPrd`4AT^trouEGrkOD91=B2;_JK)#><7>L!*n1_ z2f^fksRpJ)VNxG+;n@k(LW&QE=ObV`3MLm!N5jM)wGb~RtE1;*;CUHL$HTM&rXRqx z5+?O=B0Zl3&nLt5BZ^Oj=O4p#I!rz?KRq|Vb0bX66sz^DhS#kyoe5JrOdT+(k4|_# z3#KlLyWx2aOlv7#56@@AbPh~EAv+JA!}MCM;{tfy3)6)#{S>B)VbUHEczqd6SHSc$ zn689LeMI4T158)LbS+H3gy~l>ZQ1nj*Z1!J@ENbCXY6{%(kp*+s0?KiEQuz9;nKFds+dF{nBcF4SS z^I@s0cgWX7|8>)cdl$Z1Hvi_Xm#;ectv{V}aOsk|Tc3UAv`63WzU7cWdf_Q+?mqd= z$jisxJo6ugmv)=}w*5DjiO=}QepLI9JGcBjp9wv_CjQVbPX8u*@f-g=YuYY5|Lnd~ ze*VNYOZIf#y6=`d9=&nzHv>;CzHI)3N1pZLJw9t0_|=h)$CK-BKI+;_etuy85y=NS zFMoR4@r5UMD(!Y-;K@lpIO2_oUwXd!`!T<{@UG+Dyy~m{Ywoyh*RP+S|L>c2+2N6; zZ)Dy%^Rm}}H0Ral_dLD%<`as!v%E9sr)MSGchBuvi0z-A?7Vc!v*-JM`{__3-J-S4cQf3axZQ`gPB>Zu2po_@z` z!Th6D{=J{vH0jM(8ZU}J)6}*1As-yE@!+E;|EBSedpt2&xj!&|+Ue78e(&<*Py3=} zV7tE_d-G{c?e8zTw5ij5%d)fP|K;#s2VZ*j@RyI=sddGZ|I_m7y^C@mpLyT2bFQj>@v4)% z=N$aRzE?bdj{C)9Z+_$lt}r&p)`GiO&Wzfz~4U+FB@8IYDr?OwWHW#3Py{yZ!f{C~vb8lC03OD7LA53bzq z!SgHog*yG&PiJ}Wh68V<<#p=Rb1s}jR4Sj+S-+EODwn@Xr$0MYRW5(wA(iua@;;Tz zck1NrWSu;psIy$R>a4Frbo&1cY(ACpbCOQ^b~r(*R6Y*l8B#IgF#!r!DxZK&x{@n8 z>+!ET%jMVU|Ic*F_wG@--E(xd(|_q~Ur*}Pe+evCrR~V%sO*b$miK9$`oGr6=N(Xg zrFm3n$Gf$)v4!Bo$c|*I{Vw0PCd8i^v9z!4#&cW!%FM@6P-M?>Uct@ z-7Px(d`!n*)EN&C>XiSBPP_N(_^UeoKSXDLyDKE8Qhv7Rl-~^oTcz@?_R9Wuoqi7J z_#Haq>Rz4vysWeT{F6@kTXeihXMDEl^mB$zJ#Xz$IS=aR>`Hn2T&JEUo#nbgCqK9D zRJs0_bjHKoI{TAHb(S~jtX%)SIzAEVskC3(L#O^rb^HXVq*6U;o#V)7I{A4}=eXw6 zDgOr@-(F|`ziC$Gep+;v>t3Du_dlp|p3OSz@i#hNr*pi1SEoO-bn4lp`i z8J%{Y*YOoP=XGD}+GL*)>*D|b@DJ# z=X~QmoqqPsuDra3eJZ;b`cr9twMi#G@95-nmd^hF0-bt})5*hXoqRs8vz^|dlZPX9 zjxQ^9#z_{w4ym*qy{gmhS32wWD4p_?b=qyy$xp4$a@FYM?I@k{pYKw6xvteYUs|ox zpB|m{va3$JRXPsCT(Q9%9sh?; zyAGXp=jixVI{lfcvmWQouAKiFI_00JQ9u7~uh{mqLI&YGtMY#pr3b<(;7Sp{cP*+b z3LejwKgVsa#9v2Ozi(0>hnBBTZm*>G#(Lm1qx@J(^`zg&^6Gb1>cc-1>yMv{Unshf zmD&a9`PswkdAGdZgMKbTPeOjxhx)yh+E4$rSkFO_FZD6Md|nFunT+LU!{^fS<3L>A z_^Vj{2Ce_pAOAJz1uFh;s^9fE_W$qXDi^T|cB=p7{juHjU08pL zeEb6HKP_*7>T%AZdOEPY_I>h0b7_6qaJlfmigF#*qwIqcr+WSZ$JtrRE{fv|I5C5V zR{ld9*6(V@`Y(d*Uwt&ePDx#^_$%1|BaoFN;p=DB!@tGye~06Q`mmI*2X3$A7h}y+ z$sd?c^^5iRI;@BC`dWeoOO%K0_eEFE!}|T@+TpuD7)a&vPhx+1sC;rC%I6v^@1*kY zYV{w3^#`awlcr%k`8?L+qI&Af`#o5$>Dd1!s^@PDu)J+Q^!v!CKtt;Gk{AB$?pQuN z5&N^8>YrXdPK_LgyX}VM(_64VMXKL+0QtRGkCp1#f!4d9wo~jC+IU1JtDfH8*pD*r*kq5PyHxLhZa2kE$@h~v&Pv|aeeVtH-*^-RTf{gW^c9@?&trF<&4 zV)-EL=V#cke7XT$TVG$${vqSW3T~kCwX`1#&&2Xto?oToLR=izZafg{cYTQELvVaj zA8WPo`9J9D`K9{Uk@hp`KJ?|t%8gXsW=7Yx-#ux${12iBX}!EeUL;Y5Xf0tU%izKIFvZiZ8@G|BL#0tc~*j9M+@l59iZODSI#agpB)qqdO(|*`CFudLU%b)k*xbq0QwjG6NKOd)Y2*aTK@RmP!z<$Px z{q&PBf`MAzexJa4t|p%a^{B_e5FH1-KS>n!hMG_I6cSdX^7FVn`)>*zhu1@& z-BaE5F}gOMo95DXP1}+BIYfOVVZW;O-@OZt^DwP<6YWpJhvD)%Y5W&yKcD^+*6%9U zqA0u5{vp3&_?d>uH=Qk79Y~UimSJ#$osqbS*#gXuDH>g6*D2F1~~s#A2Vscl-~pM zqishQ(01ga(oz|82?8eiuJg} ze)1o*yvmo@?!%Ob_h=mEyI{x59(QQRol|jnlrpD(Oaah&*H!{yS-pFImbd=}OdqV4EjTJK?6@7ngbKdpCp ze*X4sT)%Sv7J%l;$Jzg2yW0MFHY`VZ|3IIcwBxc3R)^~0_1K>bs>ofI7a)N_$=11orfPm=OMQ9u|Eac zUamNZ>K}*oYo8PS+W9%$Bc(pH{KvKNOy|AY{^1O*{)@1Doa#Reb`(@@_dqjc@-1(U)$ea3da$(UEA~M=fZh|`WQ$3iNB5QYTLyJv>nOwUd88I-QL;Z>+Y%V z>hbv$-?9^r_cb+lHLq^%?rH8i@%Y7UogK|5);F{@<9n6g@im6(eJ!mW^=++ZL&1|e z*0pvt`BpY}uW5(ylohLb7Wkk{sJgnkrlG#OwGkTZYVBCPtZ`LOb#-H)zRTCsRo~jv zy{xfrRXzNtZlUjZUt?EueNS`U%Jz1py}7-yJqT4nd8cn>0~Bv=hK@p!_U4Wr&oJ)j zY-;vFGiuYTdTNHscQrTEx7BwvHv3vSyL`=EP()|Wm8&2Zr!=*B*0y8+s~s(^ZOz^5 zAuH|Lvdj(E_XOtomP4u*SJ$oGM#1X2ut?p(w$>gWEJ{zeHp8L5lWVHyVa>iqSm4#2 zUF)?sd9&5?+UmP|eCxVedzyVct?iJR<5sNl4eL$aO2~wxxvLA7uMyHPvQG=uXWI^(|`;w!!LXYlcD7*jDFRv8u*5udH>@g^qP<7GYH^99(s+9o@|^ z;_6|T46c<1ot3f5;luSYY%RBTY-{D0M`Z&HNxFc?g?~tuA`_KdO+`f zmvLI{fVJAx+8yldZZ5Ah=;K14uiS#Kt#dUD_43;A9M_2JLR($mW<#hC2BDjEPzUAF zG1x+L$LgNIP!mnfYg?Pw`PMdfb+>kQC=|AKwDxGPgI&$tu*D39oo#Kt){fwsL20ZH z_N?h@)(Uht_xJ*>O-;>%wW-qB)*5W+tnX^linVk$uIbi-?!cO!rp|R8lwuguA?N_? zYC<7j5W3c>?kIZJ`_?*?_WHKA&PD~c$1|06um=dXHYv@a=Ek)_1$Ge~jluQma~rg- z?g9c`>Qj5?+Gb^KcSjI*Aw4ZHFJwn)X>5a4ptSTfx3wuP-ObHsDlM@8Xi{3r8;sJ@ z-r3ZmoYmdg)uTX*^=;}FNT*?zWT+Wu>?`Tn=>*d6#wP6l=I?q(A=;>@LH_;vJP@n0kr*lo4($rSYL{l54 zqN%NWeY*m$8_)EuZthW+R|&$vgrxY+Y+g^B>mWgq?qG9cYfEe6puwshVeM;Rb;6K= zHLBHy)YJ_9>0B>VZC=ot4#d_Vtgpt7o;Fx|*ho4$VS|B9Q60EYQ+cydpa&|#;Rgub zRG^#c7-{Wng!#H@2*6QE9W+X7M`urK%ldNfst2R`rY7II@(uvXHFm-(P;=1K+NCC^ zyIFmq&DhmGR1$^{BrFIUhyopeUMOo@sJ;5e#^&yB1=gSfM>AMm-V) zyBO;4X>4|c?&)lY_d7da3+-72Bc`#uxZV}bp`N<6>UQZ} z+T4Tlc)DP9cJ)Ag$EZE=F2Ve26)sl$t~H5=4>dWB>zZKkI_hC%!?97_QXp~V5$W@V z;4t5##=!>I+|(qrwRW7Tw4YhdML_*;i#iOHmgY_vnXnO*H=FVX0Hz*Q$^{ygH63bG zN?TKND5!2{@ZZ4tU?*%e_r2@l1Cy?XR$SkqGJXs;d~;PhZLCFhmb*5Hw` z#-|?RVgIVu4yO!D@DN;Xci!MhMst1p)?1#B!;9utyP|int6v32%vIIuiNT6(9~2K& z_t@$=0UY+KVb|Bz+5jg&zWVO+aj&JmwGDRXt#D|A|HJr)H3OSzV*oZ)bq@pMzN1N9 zFr}@$E8_*!d0{2i_pEK{#_W|(A?B}c?r83Ue85(se-1GpPpW)y%GKmk*M6n5fK`XW z0e2hc5!G|4CfLb*i+ZRut#y{_8m&GUg|Jn?Igsz$)r{ka)mUBAxTXs>g&v=Jo>pl& zx4n>#X4uiMh22j_Po)Y*m8f}xxrS4+HI*ycc27og@&}`LJ{)^GJ36Z!<-=RB6ONF} z;3Tmcwib0b!GRn0$8{@K&Gk8()^wG3w#(X9^~@{3T(-Qex(+6X_NXgAWarkXb$4{b zu4F{c=i@%7Qm$kzBj&Ak;LJYQ*?y-Av{S1ZCybyqa0av%J|YZ`COB(`h3xEtQ+4&c z+2JdnzC$W})eDA7bq3WPa(A72D!SG4=+QM+dA9;tQg_gm7IIW0sv6GW-~_(BMk`g) zE_2<}wWdSeEsR`Y^SXoejTL($>lm@V=BxXlHQkjuu+4d^wf)dw<||b*s$`6+rfqj< zF70j9J@IIl%GZWR)Dfwvu7)Fbduz9P5?(PuFzV*1MY_x5uSR|8(}texYfEDQ&Rr{Q zZO+ziUo(7`Twf`9vYHW{)JAZ%b8sd2zK@M$6bJpqHM)m!C!ESaDw;d$TGi76-?Gkb zpL%iyDJTzL^~fs^{Rww~Rz9tR?E+31)w8quHXocUz=_Ua??zG4 z5_MT{9l_=WryrfTl{s6X=jv66p@m(DrzP-4#Z#fV>PdBZ&r{+2rP_z5N}X-EbE;5e z#HAE<4)uI!8|{kzmKO*|%P1Cq;hGM(UewaoxlT=5`2)6kw(Ns^w!;OXO8cCJjh(^u zx;rn}=+%94O}UWXHZfu_e}_7$w}WG#In>x(-uq)dDxC*b&sPtkbgia*K1@fGZ+(5M zyv1tApjC@~qdnY=?16UP3y1lN=dL5?fMHQyPy8~7`eDbnl?;q-hyDLPbK5vdkFMJ= z0JL-z9kMi`oLYvz>>m51I;E~|_8{XR*&v0_JtuyP9{oMMs z%aP@)M|0{ua<;7=R!0^e)pgnLINSfx8GVJ`{BO=!Mpn0#&jBL~Z|lT=83j=bIKC!S{YI$v{# zb`~`3vvB#c-&Q_RZ}W7Kj>uc7VpON=cwFClEfto&W?TKQG!Os$ucd06%jv9d=u{%_uLG%`1OLQztVDfi8?E%(np>q{QgGoEULnX zq+sxW-)C5BkWEM-eq+=C*XGM7r0QBKe|O2>=<2Fp@9Ti?ci?&yeC< z+{i6qA$+IO+0hCgOezf~nGD#~w5f}z*0Z$*Tv*<)N12R6;rk((z2V!@);l?Jp{nPv zYpy@DZ9QD@-O9pkYay!Vx52k_>PI#hTAf|+eO#qMy)f9-8ES2Zk8_n0NN2EH%T@Kl zp=NkpmG^U6uD)wy&Y{LwRTbMBT^8jK8Tt8>FBE)!9kx@f8_kgsE?VLM(OKw`T{!XN z74Y>le3T2-H?*#;u7TIxt#E}GzGLw9tPiSJHCj5$*8rBmNE^jxc^YG*C^@eczE)Ak z0$g#X@8?E+#b7>srw5l=>O76`#ouVZ{8^xW4bs`tQU@2S;rfqyRcI9m?B>)(8*!Cr zbjw?=6u!ByTh%kS1)i5x!#6eU4eAv@xWfYUJJ$@ScpZ&{8`3Bey0AOYxvsswWBs?9 zYksI3&iFc5Do1r$XxfxJJ>NA{t!aYN4dwgqpTAa()EpLY#lIFrxw;a~> z;G62DMn_#8T?Z?Fp*Z;7XlA6w;5HE6T=`nyvUOUu>UqYus+E0}@a^VvlouaPCgy5q z8E~hAud~J1RS)0d`dZq1VEvrEm8{|^z@dYuvO~w`t?ls_sNb~134pqj*!GvEgQX!Y z3kJWvgJ2RwFPDCc9*^=E4VMeL;pFDqoV2J{k>SR-Cb)J6pS;xT{ey$8!ufr* z16m09;0wj_&U5HnC|DL)@M@o1eY;_OPjk2DxV3QKoBkQd$e}(@T}rwSO=rdE%rta0 zzEYn@TofAIF!auqN6dkju3^CQOQ-BQ^XQtzfeejm2iMWwL$yG?=c#LLbKSOX=Bu7Z zUqQ;#P@NXnsVj}&zPL`keQ{k~-H6-5zC*9oTOi?3-nzO&-Gt_D^TQOLjt*^{-#$-j zi*PTGdY0GTPIsGa^$fUr?zjAGTMbuk;2s(E4ovMte{lBzxvVe_$~&Ln@}a%Rw_mL4 zd9YksyN#V~a3@`3dGTmC=&W4bdsE9Rm+E7Q+DiY+s}gVGgHqpO|F(@f8#~pnb=4D< z@2Adc*ivxy4DHVN`Z*N7M=sx3fZu)8z*_kZ16%uAYWVIz-PcuK+>v`N_)6`&wX8N! z?)%`~vFe_3_+kw^Wq{*S`HW%Ny2jxrhtP$=-LuY-zWUInPG5U{h(504fkACgr~G^< z4u|HtmBU){9JdZO{*^F!8duGS7xOE8Sur?-N9=mPbE^1F5+l>5%FuUV|JTir_f#fcdYwn_MS1s zbl$FZz>r`obgGyKHMGJJPK^0RZOpG@V}2{!PIWcZ(+v0PHo-aL zHn+5DbyKTvTUQVF8gz83BV(JhEQC4mvRlQzw!?}MF0d5&)Qo(z@? z|3O1tgSwL88~qwb`AVU7?W$Y80@ks*$m*98HNNHQjmL2NY-{8Cx|+JWq1zzT`!~L)I*0z*#i;7s`ki5L zT!+QLTQR@qh57y({+3HT$~AMss(IQ`WmJhARkhW1TD9ti`@PlMxV8be*3nk_{UyH! zmT2(sJE{fx{@PZviEWQMo8MdGm~yz-*3zWzW7G~*IQnnBxdqz2Jmo#hcdBAu6WpK^ z7}`~T>prOGx8*xS;h((0J!Qj>72mqHt$$;*^=#D4gMT!n{#No_qE7KDY(4rAd7O0}&5(l}2Hu@AeiB{$sIi z{{uPgTz2c}IyBfB{9fzQj<*$3RB4Imv|hV|Og&-vzq^b1|7m02|GFxFPYeI(2>!K$ zdKayFlSKWR5L|>);2&ks_2oI;opT&&tln$7bopSobh&r%bytu2vRWKW(IYxUppmLu3w%CG4ep2|ILgBdI7%ldV%N8sc|Taz22j1m-(va96Dzq51cF*K2P z9sF+`{IxVy$3ZRI!Rw(i>T47H>V_G9<7m5bofF}CEc`oa^}jT;ON8C8FAL@RApc7(aqxz(~JWheNr)_=8}8GK^7Cu+vD`mv0q z{N99eU1|;Ly!g(Tayxw8sqf%gQ{OZG_kWcE+;G&|FD?HK2}Q91dG=iof04nWOr$?3 zpuWF|y?@~wSJu6%-%OEXVd&qct2RW4*rtF zj>=4^U!7L@;|JpXozhvNEjJVEhMyrw+= zc%_5lp8c#s?fNAIiUFDd6^C*=~V50}q0SYG=*y0P?k zB;wQY4`t-&cj^Xx26|q&i^>;-FQ@WF;aaS`jen;?t6v!hf3O1{X5pb_=oaA-a;xwd zxlMSS+%7yx?hu|LcM8vtyM$-S-NJL^9^rX%ukZr7UwDZ;AY3^X^Ai+qCJza>kcWla z$RolXH@|bWpd0e=cJRv+lo)jJ;PYI8Zr-f%7n1_t;=xOi=ec+K5?lGh1gy*(H z&kJ`aO)<_b3(X{$|r^Asoj)t7qy!fZYR$O4>VyOvcerT=sDp| zswXeJK>aBQFOnC9m&i-PmE*8o<=x@q-%M^6ZXve_w~||h+sJLg?c{dh4swTZC%IF& zi`*sLP3{)%A@>OPl6!^w$^F6uHdpqoBTDXTiBiv7(6&@fj2)75Y{-W@}Y3L;#H@`o8JUgf!i*P5o zRmbhZT~yv7T?x%W^ z!UN% zZV{d!w+c^^+k~gc?ZVUK4&fPcr*N%)m+-)W`1#!}JVfph9wGM%kCFR@C&>fCGvqbJWZYwo+VEUFOX-1D+l9x$qKiS z=Y-qH^THkE1>r98qHqs+NqB%<`EdC7PprjXlQIjpt%pBc4iAfPKe<(Soa(U)cl2QW z4&hO9r|=B9OSpdx*5ekwiQFSRP4xtXhslG&Bh>$p@F;m$$0Ir(6&|K~;=&Kw6Z4$V z@uZHYbUZCQ*^TvQbUZ6uc^LDY)A78H7j(QR+;S7vU(#{qqv7MTK>ac6xJAdUI&Ks0 zr~2(W?hqb$1pDvQahHy}b=)JoiSpys@qqATHU5rBP`HCUBs}TB@?qf)@|f_X`oqld zNC@{({Yl|jswXAfL!K6%rTTNiJ>+@e>HTp$7lg;{=tbd*4ZS41NZXzA@$m7c)o&KA z)o&55)o&H9)o&A?t-=1-g%_&P9l{g)p*w}Yw=cR&__GdlxA4Lt=pNzYs2;EI<@;cH zzwl@0pa+B(4n_|OZ<>uB5+2_hJuF+|1sfO{^P>^2VuJj;kEvAk1w^b2&C@LMlOcMErudxdY>jOG2pCzA(+r=wUtD7=Y0EZkqh@)6`mMsX`fb9)E3jR=@GFi-cL+|1sfO z{^P>qwb*V#__(9dlfuJ`&{M*TN1~^N#}~j4z`!G;<2m6?S7JSR;qM)aUKAex1id7k z+BJVRd^~IQTZC)%TZP*`#QJT*!((u{9Kzk-;CAX1o?n3VxP&MEi|!U4{2JXWJn=Qw z?-#C6{Q=>Qcd(wI@V3q9Vc}+~KO)>VAInFDy9Ur>!nO6D5U%AvDO}5cO8BPNvHrAh zn+x-wmHrj&KZO_OVm*1`%loi=LHIcGqVV)BSiU6O6~OBQ%ICw!o15G$+(T{=?j^Sg zx2Zq02@kvQ^!?}#;mYCgLtOB13crZlC0scI%e#fU_Cxmw&y#zF`xjw(zwkJDK)9tA z%Lj$K$V0-HABE+^!d+BnM^6ers1rRU+(n)i zZX3Y$m=QjkJS)6Fo)d0+1MA64e;d6ZJo+E>qHx!{=q2Id_t2Hn@Npj99hcWE+&&K7 zBHTY7-74HQ8QmuQ)*aF9!Ywi@|bY{ZdgxTcz93rgz&TtJt=(hEcBG{ zC&<&nl`1Ts5nd$E3b)V3@;Tx8ebDp515~~s{7&+s@X1t9NqBx=tY6tYeEd78yji$n z$MP28{=?9%!o%b?;c;@ia6j$G9Kx3@!TO!T{p2p;7m>S#Pp-pyJi`6Qp!v}XFLAZ_FOTV9}Etj9l`K8nE1P6qN$%E3*!FEH!^VDuwxSu>KJWF|q z3D@$F5bn4R^O+Q`l}`(gP@EB-rr$r#3eS`0g!^~H-<8Y@x9y5v5S}M536E2(d^LP~ zk5jv5;R@Ab5uT_1ScRvlyiK@^Jpaw`cD40V5bnAkx1*A9+X6gZo41I1sJu(KN82uh zyRO9R#(v=`%1=OekhZ&^a1WIa3D45+XUBynX}l$bZzN9(&(iji5niNr9TRamwCyYA zr0-jWYxh_Cg=_aYhlFeQt4D=v_hTo7Ywf0mYxmXXglqS27lmv0&zmO=UoP!Fd7E&p zU8ivEzH5(g?f&h6aP5BnuyneAJ1RW66z?mD33pTZxNt9dLb#tiDLg=)5*{Q^3(r!2 zGQx9i>}OVZp33KhTc~_qc#+B%q*M8#@Dh1RxS9H|Y(IScYW+70*ZOG@uJy+%T%mSt z!nNhH3%5~uhj1skQ@ESlCEQEy79Jq?2#=6^g(t}U!qemd;i2tt{RV|6X?=x+r^v&? zGvpEBSDa`nf9{S7kC4ZN7th50#D)7i&=bOgR!rd35`-OWhLJtV{{uDhZ+hwc#`|24W-c;Hrazi``c&;!DA_+sM@ZuflapBhAp(lhp??O)s58REO5*|yUr-gg&LC**e-HV9L(Ul#CkN?6$=w{*8htVy<-H)JKg$Ex+w+T-@hHe*bejME) z+}V%r6dw2^x=XnA33Rvc47o>m_(?4974Clu-7h@+C-i{uOa?tDy!2=EkZ|WS=wac3 zXVD|VV}C)93J*Vr9uuB^9z8C+ltoVncf5d}6dw2+dP;co@91gasTa{R!t*boXN6mG z=sDpr^1Sfq%UHf3-2DoAQFwv8B;5QjEU)Y|eEi#AMK=o%zJ_iQo_ZbKD%_t(w+T1@ z8{IBEN$wEt9l-KV;mRB6F5!+%=x*Wu|Dbz>N8d#E3Qxa`U~5@Z4AEMd8+O&`ZMYThJBD@bT|eb}OIfn1u(&pj(8S zP3TtPA#$7W;8-kg7akjj?hu|DkM0znoq+BVu9(r?!YvcgJ;I%n(7nRF+oSu1dnTg? zr0;+p6dv3WJtRD}6M9&-#BS&r;hEjhv%>Q;(R0F;J<;>RZF`{?gu86$Md6`Y=q2ItDs*M%;p0EOH@aDP zaW=X|xMg2-t8mwT=r-X2JGxzXbboY*@bm%bPT|P|(Otq*2cf%#=MF~q2sa;s?iKEI zp!OCiI+eZ!>ycxU&VlAUsQ66kc47>WIcLVc=T-ah;Z*Y=uzRebJ1hM3*>R( z$)8~Pgz)Tn=t<$l^U+hnl?%|*!tK528R6~=(X+w>7oq2bM}LZ*7oNNry&yb$33^d@ zF@jzaZn+FynL2#@J1$2z3wK|EZV?{%8M;+?9O*?vJC#g@&NB0Q#KY;EP9!jD6g~uL54+u{`gdP-L_yc-KxaAS_uyE(2 z=n>)m$Izp~BWd)Q@Z{s@apAds^n`Hp6X;3d&L`1R!edXNr-hgPgq{&@|1)}4c;spH zobdEB=y~C#XVDA79nYZ`g$JHTF9|P^E7OOMf9qedyji&81$2vW&)?9k!UKOtw+Rou zh;A1iehJ+nJoXQCr|@(R-6g#6GP+y1<)7#t;f{Zydxg7SMfVGLzJ?wU9(o-;C_J7= z4+&5I8$B$%Fn}HrZrg+&74H2HdQ5ouP4u|%;9KYk;g$k=Qn>kT^px-{d0Kel9W0*_ z9(xx(D?IuhdQN!pzvy}4jt|fa!h=QhqVVL0=q2HWkITQMhL+dP#U>8oFW~KK_%_(apm1 zGte!q0G(8Izl`=dvMyAD8)3XdF!9uuBD2t6*mbTE2CxcdN6!n7Ef3NQT_ zy(HZ3LsxbiKK?y^bhGeKJ-S7BssY_9yx53t6K-urw+r{QpgV+zR--$GCj#g$;n`Mn zw{Yc5bdPX*8@gAxtsUJj+}nX35FYD94+_r)(L=(`UFc!q)^7BO@EmzmxMdBNj|um# zMUM+Fktc*l)?xXi@L&i%CEU9nJuTdQHhM<5^BnZ7aL2jmIpOx7py!1b$P2=g=VAGx z@a*~MCE=woy0ZK5@o(!zHw*V(h;9)czX;tbJpEI2oAAQL=yu_@2)aYK_fmAH@bG2m zF5!vG(cQwcSD<@@7k`HC6>j-Cx?i{>iXIRi+<+bw9={4bBs}s9^sw;I)#wr7o@>yf z!p+yB$AsIiLyrp&lP82*V^}^ZyhNT7Ui>ANPYbvG3Oys-a|3!-c<@H_obc35=y~CV zo6!rxt+${TrT3wigu8x?uFM=h{zJE-n}s`ngKiOCBDV^+{T9pHgxeG7cH!>Z(H+7A z8_}J@!*`&&gh%c~cMDJa4&5U>dl$M_c=J4|-5|?q2keaOFPqu<$r} zM0oJ`SUxJ;eLs3kxa9%#xNv(4Jt5rlAbL`G=ppo!@Yo;F)56maqi2L?A3@IwH$R4+ z6Yfl-=Y@M8M=uDs^`jSsd;W-C5^jG2UD;#!_%}a^ZWiu*3f&^y`zLg(@JI&TCOrOU zbi45U)94Q2rDxEc!tH-ScL{erhwc_0d>-8+Jdj2A3J?Dk-7h@$0(wAr@^9!t;n}~V zhlJZ-LJte~`~y8AJ%=6@9(ox)COrNMdR%z+pXdqUg@2(Zg)6V2r-bLo)52q~WBH8m zWF9>$Jn(Pyobc!XdR}<;4fKNW(kAqxaNC>cCE+=8WzXT`Kl&DyHw#Y}&@I9XZ=+j< zN8UlV3AeqAZWr!;58WX=_+NCV@Ywt4F5$Tk(A~lEN>TX*$Let+-yO23b*Wx?h@{pg6n9X%ktFatd(+`KD#NVt7B^ssQx?&uNWp_%AW;qg7tW5P3gqQ`|7_CikxH_t*( z3b$3Er-ZxqMo$a(&qmJ(5AB1V6&~9cJtsW9A9`MR!H!-KZaDzGDBO7C@4@!t*|Kzi_1a+h!ixktE@+%Mck9u)2-4-5B@M}>RIo)lgnPYW-SXN8x@^TL($X#5K|lPj}_=ifqZ5pE^73Ad3ugxkqo z!X4xu;ZAbDa2I({xSKpI+(RA}?j?^4_md}u2guXHgXCG^A@aQNFnLjUgk0HYc>bg0 z7U3~+oA5ZfLwJJRB|J&)5uPIV3r~{=g=fga!n5R2;W_fS@H}}^c!4}EyhxrEULwy6 zSI(#LFWgM7>^nUF7IKSlE4fX$jocyJPVN%!AomD&lKX|b$b-V&cI`JYGPL3%9+6o)GT(4|-C#@& zC!uGA`*%Ul3b*Zqo)d045j`*b&K=PU!foV5;o%*yd`WoIDs;s$eEg>;qnm}lw>`Q= zxP{y*JWS)qCfqg&>#+-;JP}>{-8pT1+IB~Git_ng(OtsBKSXy6zru>H{qCGrf4l+R zE6N{4?iU`f$MON;1wVRFxQ#p{+~dRYVc~(((Idj6KSqxVkDrDflYT0CT=+$&peKZf ze}tYC?&&~J2_M&vo)(@w6Fno`bq0D?c%&6QC)`1v7oHAa`GWA})mXkH+(oWb z4TZLzu&~3seliP*+8?n6hJB`}-^mm~FCmW#Pg}5j zOn8v$j|+EE`GoK^?T3@X6MJC&DdG8<=xO0;FM39}naXE{hfl-v(wy+bkI@Ul{Zzgv zTsv=9YKD*JJk?_sUh-l6+V3ii8KWrIsDFpF{I9d%KlFFdrCW8}q2t=`7|43G-zAW) z{r!IF+TYQa9-zMyulnU$-{CpR9G4W|j4}TeD<5aRoOyzI1M?*F)yz}O4`!Zb{tWXB z^8oWK^L?1-n71;|Gk=$Pf%zonMdn^`ZN1ysdcx&4;S;f8p5-exw>w& zM_9)r%$Kq9QRb7F$C%r+%Hg@F<8kH>vhpPzPcUE4$}99c0O~SvPqOk(R^H6Yb5F7I zK~~-8XUl70{W+Amjrn5ccII`=9n6ns?qu#^?q+@g zb1(CYn1`9)&OE~WUglBe%UK>`%cGwyp8!3R^HBh2j&ju<5@jU<`bB^m~Y42&D_k~ z!+auhFLSG3F;T zk2A+JBCIFD9DjikJ;{73>raY#C#yfrd>SjCVUE9OgKuV;<1ZPZ=a}O!`=IBU(_b_# zzfoYmkoBj?9RKAz)>C4RcQ&FcV}`~*U5PEfVP?+HTP)1?qAJuUD{~uj8}nJr?ac8P zeeg{ObL<(qllg2`kBfPUxtsYu%stHaW$tCZA9Fu*6U%de`TneYkof`3L(C6k9%gpU(A*(!JL22N;2o?S1INzSp8|{Cos=2{{izX^OekV%vUkbGe41ef%!?y zi_A}EUSj@BmIr0*(D?r$D{p50Bjy(7r!coNKb5(S`Dx7U%zw<>!TfaQPUdy2|1RcN zFn2RQoVka&kJayG?q}|2{webS^Lpk%=KOPdh{D-U_7xVp?yP2QP+{3(^ z)$e8A!`#n&4f6o=wakOe*D((<4>1ojU(YI`IpSI%&XXP<(T)f@_FVLGA}UyDf1%pi~TG&oTcc^E~tGnHQMB%N!#u#ejrAwU z{5DoT#QbWaU%Lf5$w{{4VAh z=65sCGEXwkF~5g-p85YUFEGE4d6D_PY`IFzf6vM*+YOEX2bi0gr^E4|TWd1nw z5c7WKVdj5i9%23j^C%t2bd?Bzrj4kd=v9D^J`dtGR*g6o@M?YR!@%k zo6Pgf-(p^1USM8i{xXXXXXXX&0F^ceh(4rJ~{$}ZB%fO-yHq%}&?PVMLqBcb>N_1zFYqQ0q zl;11I6Ckc)@g#_AS-c~}4J@7laW9LfL)^#W-5~B~@tzP5u(%3h)0m<5_l3BM#RouK z%i=i@H?X({;$9Zdhq#Z$he6!W;-erQU~w(PCeu*+$3R@g;$tDMW$_A#8(6#&;$9Y? z3~?WePl33f#iv6&z~TmoO=E}JZ-Kaq#b-cV%i<1*8(7>0aW9M4LfpsVvmx$h@p%vr zu(%gu)3~AbFNU~^#g{=`%i=2`Zea0M5cjh9T8R5td_BbdEWQci0T%Z`Y#Kk*{%sIf zv3MiIwJg31;szGq3vn-t?}xaL#ScN;&*DcR9$;}l#HI;D?LP%^6^oySxR%AwLEOON z7a;Cs@kxR1rVLEO*cJs}=oaTUa- zi9_x03vm^T4}iFq#d9ETU~vt^y)2#&aUY8hgSel?M?pNm;#!DJlZM(q2I49f9}96U zi&sG0z~Yq<_p^J{{r#7B@g_+J3107Kp1@d(i+dq9O&)6hVu-6)d>O>GEWQ%r1{PlhaW9Lng}9H!*F)UT;+r5I zU~wPBrX7abzYXFl7H@>Omc@5L+`!^{A?{`I{Sf!D_#ue-=Q!TV_8ESt5#8oVw1aU2kcZ9fs#Zw^eW$|=~`&hgi z#QiMZ6XF3DS3zvrd8qw;A+BQa0T9=+cn-u3EUtmLm&NlT?ql&`5cjkAD2NAGTnn*j z%24~qKwQP*VE`}$Ku@}?q~6y5D&1p3S!f)L+$SiaTSXXfVh^$b0BVDaSg=1ES?W>ABzuz zxSz#GK|H|XT8K@%4Yhv^#8oUl7UEhKuYkCL#VaB1W%0=n_p$gCi2GT5I>ZAkZh+Xd z`%wEW5LdDI42Wx4+yQX|i@PB1W${{w`&fK7#QiKj58?q9_d;x%In@5e5LdDIGKgzg zd?myUEWQfjUKU>qaUYAXhq#}`H$gnW;y#E?dknRI8^l#C-Ux9mi|>NCfyMVi+{@zo zA?{=GLlF0~_)&-lSlkb>Y0shdpMtoG#ZN53ty@1lqTi z+b`EQ0pcnaPlC9X#XCaWz~U(o_p*38#Cn|u%^GTdUx=$%d;r9? zES>{#1B+`Q?q%_Oi2GQ47{vW7J__Oi7S}>-sv2tl7>KJ_d@RJZEM5U|1B+Ke+{@yV zA?{=GDG>Lw_;iQ|Slj@yY44%-TOh7t@fi@;vbY1{1{QZg+{@y%5cje8Y>4|=d>+ID zEbfKaG<&H1iy^LJ@nsO#V$82Csjt62=h5=@r;k^Sx&4!|%0|VdYzUi_%@-)j@BP~= zMa8dd`1q?W*L^&8%*H%SUwygdx(1VS`M<~R0`b(1AFn$4_OH+BPt4zc{M4_{d1SDz zkD;zl#*W?i>8YmMKOJk@_}SQT8_zdQf7fMFPOP8e+U$qxO-C!reeNw=u2bu>jXS!u zmonzvumbN-a&4Y9PT6q5*ITaZhU-v|nRa=1);C*D{Kb{7(i-@j{Qj0LGt_oJJ+&6v z{$u%?+1KYhe0zAD@>lg*8ZOJYjcT9O_CJI6KZU-0zPqyd^ReSM`V{5eWn+{RZyu+7 z2+Mh*UvVw8Dayj}iZXQo`uT4tn}k%DIZ=m<>W71P~LBvdU?HR`sG_D(0pgC zb^6bk>l|C2Z^p(yL;0Iw87FS1oY(;6;!s{~>yK(%G*8^*TIe?^3lD*LKHuH7c>r?w z@3GU$?WxOy`FtXA|JPe)_>^hyCScy*n|68TTGRAreh+olo2Ff^)#X29jZ>{_X1V>T zywpf&%7?$Q%8Nyeh$jL z0p&1PQ#bZb3coNBOfCD^7w|m8wfS+AYqJ7lXbjX}596!>+Nix^>1K7DmirZ6x;gfZ zI+hj4!L*I{z8+JQBvatj7i8!&h!+myP$pyz2E@WlZ=5b)5YYyb;=Hj4s_= zZY#ZXb0cg&<+iR~x*5jB=5x#Q&^ETcvN=9Rxle7c3+5dYzy5^_u7BVoKlJ;8AHMj} ztm&VB1pU8!FIX1=bzJtIR*HqkUp*GeYS(+^a~Rqm6E|;wc}|!OZSUn7xBi>|D2FT> zr)=IkK4t^-E2$_-^5%1u-_@q~-k^l1Y`FQ{F~3_hdAr;9KG%HvBG;IOi(KPNi%wG> zToi!GWmQQ8%FAoQ<-G^S%&aL&R_#ds%iaH`=|KL8!mw7S@66Fwudw9@^zMShKi`beHrBW7yln??;amjbv1tPGc&nBkXyKf z5YEgbTm#`EkPErYNdkz9q9L|kT9}ZCwut3oEf6FVh?g2#8A%^4Qj&mFlS)e~wv0Mv z0#vP1FVM%kFaa!RsA5IQ2w~puIx~Z*Py4*T-{*b*IG?j;U)J7xt+m%)Ywfkq(4Tw# zoIKWTjyb+ZuLyalPkOfk4-bOVcac2~N6 z<|$K6wW(jiqw35(hto1u!&mT!f=?>qjnH5roa{nhejWT+?A@Evwo8$9cZ^PJ4RHeUG} z`>If&J0XX%kf%Gm#~@$edMFdwTihKkznd`x>2n(6%b#FvczBq#VdwSMhVVQUIyT=3 z^;PM53H(=_Yxs_RSB0SS^1oiD${qbTl|5o%j?11{(NB%hgWp94%wwI}cy3(O6}(!7 zl1UF!hEMhKlsV=(9t&QfLUx7BNA}&w9D`RIA=x8VrfXh9x!ww1VVLE?UaWD(Yzc>- zd4Tb(U1Mb3v#vPHW6jks<`?Py`~v-Go=e}(@BKU-JR7OEDia*B$2fYcJ%joHD@iT? zI9`?Cy?Ew04|5-$WoiXdQ{l*oucpOSy9lT%pWK4Pe)PTVr_l{iT+;c@a*@;o-w+Tczo&iWB=&kt8UUy)$=Pk{f0)?rI(FNDj3 z_s<)56XQ^Q)PP_O_{JuAS=D3l-5uR#Owk7Mq`Eh<%_ODAlyio9~GTFy`9(Zpa_3!UHbKFOi-$(th zO)VEb-8rZ+wT^b|7iGxwvLBDIHb|RN>Wd7G@#I6}6!4N;uS7ms*<&_X)t-^ck~!1b zm7@|3pUmqt`vG}LcE(aqTJ!}~th^$}&wf{HDPL=B{&B!r&$)Z%RpHCm5b>wbE3KQ;XY_-^L~_u+ecE8jMY@;CJrUQAwc8SoQ5G%fU39aR>l}~eT9fM?OIGd`xzR*DWmDHn`Y){M;_Eh*36B(?X+Zg}Rgg$qM-=5evoTIug z=4-5%SxaMh5PZFy*aHV1aB!o0ucF-+@U{*A#DU4^&0${2Hrv$$Sz>?T$kO|FFo1)J2j{#o;~IBaQU|0hW{OKmkF-w z;Gau$pnvV3;c3ULLo?mLIQ6jBu#xirm0k|`n|>Rp$2vUZK}S>WbZ_YG|2&rW7|XZE zC;R&^Sie69PVJU0Wwl?ot?-j>gC5}ocTE_X&0gfckvy5Bj8S0JZrD&Jx^eCCwiP0S zWZW`e-@_U<$Ft_T5dMFZJ>Rk;(bWHYt_|6b=jR%s^W)iT*oV(m8-ECGsQ82I<<(^+ ztlJ^2ZI(fu&pzC+)3@SCf*)r%=Y@bY*8rr!Lo_48Wot?Y`sw<}g z`6u)gy+rJd(~Mp07okrr`_eh&lEZHuP}8oGImcvfclC>=teKBam%~m`;=6KW|7ZYjXS>7C1nEyP&o+zdRFppr+!)O5z-$Jl(5xM}$kOSCHTlx~ zp6W5b@vIwV>&j^=E6!`i-gMhkP6x0f_J!sh01X6JS^)m;FFHlk{$vkwVt;l}e$K3) z+^SvXIrO3M!)(!A)SJQCDkOX1zvJ-+cw*1F@H4LoAOD9sBL~5ErJ6T=vf(>1G_J+c zU-|A$h--LMq;Kr2_6+N1gl_7i_Dtoy8Cw0A z{AX-x&$GO5hWAA;EvM|RUTV){?EA2%0Xr^5g?f3L(+Z=6sm)Ul(+2?Z7f4dOgBfJ%$t%JEa zncGxzZV_5LqjOtqwi{-)t0X;@K0~af-_p*YT?y^Vd*Gxv+U`cPU8>pc2GWDhhxdF< ze^1a((N3dXa*zJBXuGS-b}44ND@d<|ue<1P6YYY`t%Es!(PMrc(fJja?S`7|@}lib z8AChawRYMCXn(jz+xBSNEVJzpv#sbG(&tz7c_(fEm5(E`M&$J+n+l#wMusK}zrZ(V zX?K(GOO%c)d-PWu?N8R1?3uE5`jVaxzeqnl?X2$VCobtVXYtbrf1Vl>zx0zy2}{${ z4BtZ8@5hO~qqRC6rY^VqXJfG+M@Q0Gr2h+D;hl7c{+BVT{1@zddr4Q3W*zz;CEbhk z>72x+pSpT2MPB>hN&LkG)^+1ij1Y&|5~S@|es!x54H;g$%Ah2fYKmt?QPu zmj9ZF{Zks}4c?+c!5u2}=#Aj+Pbzd2J@Ms{s$Bdn9TQc#W;OD@w8nWmO5^grdP4NY z?A+i6+AUO}Ux^$<-z()UYxb+-R&V&c^1XU4{KzrnP-JhwN3%hNwu?^#JF4S6-G_yFFUVT4i>)SkDH&F4klv%lSh&grtIb*69LxyKhDkVewATEhNrZiVh?a#nA##R!foK>B^A0OE)H8D(c4vO^)eox zW#h7zWBo2;k7unVE8mcK%Ezm0sdF4y`>5k&ueLQTYdJAAQm?9EYRj=|&1a$hChE&x z9()yBC5q=Dco*8d1HJ{69`0=ff`7m!(h9r@S^*pPW8fzk@lSOq`;Z^`4@~Q{UEqg1*6G z_|G^->n9J6)~`z+t?$eoqtAYAw7#Q{&Hh>5X#KM(Hhaz9<^`)94*eLq`76M1JJNLz z@1S+G=;qg3Sj%Bw^8#7N9nm^Lr}JOxh<$x5N%J2Yo33~C)BNpYox1SRu?)@MF-G%$ zb!Eg3u_iuHCTk7dvlRzktMybw&ritH8b0Y)?ElA@V*ho{EWHXJZ`Y1EZ@;gs{YtFt z;nY7)yP%al1YPRy(DNhKdICO!F!JKFKdEkSk=8KutAu_R?>D-;Hyhm@QQKnw%MX+t zqrTXJZ@-<>*Z=mUz5-`|wC{5}RChP+f1j&0^wbGY=gdK-U1{3qvM-(*GaI>i#nSHT zcTHWOWHNsh^xmD)!G~9#@SUr}uY`*JnbP=V*goStj)=EQ78pwyNyDlz&ti z=e5S)$6N=S{mLB0hO+*>ZN)O@ME%JEOBQ~z0q$!i>bsqpdfPRbdehp%ENI;C3Fa>2 z5d2HsxyTTy(?I>x_&K)GXCi$_`(@6t`doC|F7jSxY)5$q6Uth|KadI@iskoy_}QvD zn;5U~v&aY0ajIBPF&xM6`y}-RmwR|C_Oh#~a~|GM=#A+0t!p=+TPCYeu(GVBs~WwK zJiAMM{y=Zmc!K)ez)!U*K|K>ho<52{TV$C1_S{^-X)vQSFZe}V-tm|4TV$wEk>_xM z$iI&LoWURmoXA{zLZC1mxx1}K+qJr~q~)l^(&^~0%J(CG?N)22W%GtD>F9clDl$T z$Th)NEpkd|C-_PM7h=!I9zU4$-{BvbXM~@JwVpj2e#DIo#6}3UgTF9x^taTB$@LTX z+=V6|kf%6@aqm}of23aEkdY^HD)3%Hj+FmR+U&Q&%V{PrA42wE(}V=?wfK8a2yLv^ zZ^j=AU$S$qX3UOIIQx=ZEf6_3q%+V@a51FS%DCeO86i1euv_E6=e=9f@xO{q5W~|) zl`6C|imQ*HkKn86Kj8~sg`;m*j;t-g(|4Jp*i6U4`|&TsdoGP5vX_2drHrqGLmN|@ zlfW%Bp8?H}aRwzeXes`NHgIU*^Zbh6tOc)ue{>`GaLj}b=(ib$5xSf4+pR+w`+sd& zf0_8XzZv^a7`ND<68snsycIB@UeS97mf{m&OsKd@b<4VhncncMJP_jJK);2jI+L z{%P!GVpl5GaHCc0obWY%MQqDP)>RU7fmWGnt$qHhQQCYYI*E$w>~lI?{*h^e#&s^i z-{|QKXaABqm&U_?Qap+SRo##i)^EfoZ z?wk2<3<$s~fjc>S#{aE~(oB3^p zzkt5QtWl1sHh|aX}vq&3BC!GzK5BV&d zEpjVBotS-o$EW|wx50mye7h;8dr5!n+vPI;AJg^%V-*-j;E~umocdMxkp=Ly*tT*u zDeph>yXT(&iTu6=ydd{4?d#D!KZEu`_Op+_kT?E>C@j<7r!RpgdrAl8lJ_Nf_~3Rc zxANW&4Wu8b({V9u>KCum{w#I6t9M?~^`-o5X}ni-?q6_+p$R*?=h;cj2DqfRuZZaT z9d9?LYMfcUbywt^M0}{qqrB|7WyqG$Q&*^l!1l;*AL(PG>dItnvL`D;^M2pPX@3teYE2lE*cbki@74y4w4*$mQR&-cvF!lf2ROpmB|K!$+-dB&S5Bl`|iY<`w_t&*}_+8+%kZ z4nN}grisj<`|0OZ!_Bi)!_cT7cPReP{vsH17d#B)wBx~sD@_orvS@!qv4PFKgF;5gJI|}x2T4cXg?{`zk%^L;jyUE=k#+myymbc} zQ^o#_2hZs|b@+atKdLnt169r+PihTrcuK|-{EgwWy=<(hO-Hw!Vfgwn?hx%$;2G9_ znbi3!W5ynY_K|b;9F941cya+xJ{!z?LeCDY4 zztB9!A8$A(>2>HW9l&wJ56V5s+n+V?-9P^4F%D*o;4;*)A?};;BMU@N=u25oA4BXM zDWHy`c3yPJoN9^p6Ii(_KXwA8T#!xUkmf?IiJSp{ABJjN5#BV z?j(Hxa~E4DHpjQ=t1q%DhNoVXi+@}dKM5a<(Zju5y6Z;eznnawU&nL!cqS;{9sHg} z-QYNIoI6opByfA+t{u3)kHQsxW^d0t4$Z`#wiugC+2!6r-~C7F_Wllin{|xd2VMKq zUK4eVGsDxpCg|wwzSlx_`zG|?*RGkMFLO@N>#rHld!l~yujBRb+sYpt#d)`^GuGf` zC&J~un6IqEvB;S5JhB##T9|v-eOPBcALna+@x32I9~Qs0@Y2PnsWaZCe_ZO&gFNBq zl>fgtFL%)Xm^JQFy;E0ferJ>NK1A82J~{7D(C*lGSnmlcyE-o-e^T!*+@geINMC+t3lpn;qUhJ zGyEyogJMH>Jgt2Fpe=ijuQAE+i|li8<{KW)*g5a@;G?fjP}kFT$)dxaR3~SE0#|HB zQ%=rd{b5UN<6KqFOGc75kVkg-V+8i8oJoX-PtwE4o@2l(S-ZV=M4lp3<|<$QeCDZC zj=;1Vmb_KK?VYL051^m*1@Grs%frBj_}4{`5Sfy~p4_qIrj|g7@&+slU&rvyKp$ix zYrUP{f!@mWgS@w1?+REJboJ!xx%m;ku0VF>C5Rjtqrc&F>7UM|JCte0$NG!^9|Q*he3rcW9pQ!=CUg=Bst$+rlTIKGy~{ZXpik zH`~91KZ|lHQ=4kH3_VqSSo8vPmq*0zv8uFsH~WsUq@b%K`KA_`hx_u#Jc`M4C#p~t zaVY0&hP8w@#OH~A+EfC}Qsw!Kg=>WqbP#8YtkI<_ z48PcVpXTfKUGF*8lG69@Bs2kd)8ipZ*-q`~jep_23?~W%`gKvP!4nc>boauO2J7O>OjOBACMdbg_ zp+g>a@=?pMPV~a^cMgR2bU;s`;r*1QvtFu^3+^49o8w~@-R2+AQRI)zS;nmbnpY#1 z(K9f1Z@5H7cH&QzT#03e?6lE{{ zMq|J9l&uq3%;oF%W$dX-p2COWAb%D4BHOA4q9fVy%j4I$@JJ(bjr6IpUT(c9BAY~S zbf71`H(u=#IV$^0IDedu-spdgH6*+*ebtt!22WN*4|4Kb8>xItDW4AN7E{L#zm5!l zMDzdly~rFN`we`K%&sUj-`O7ee)tB}@O^&6!`ZLpsD_Jg8-9`V_EUzr7l!l4>t`}1 z=#AI7OnG~hx99;vzsZbc4)L0!zCHhuJ?B4GM>qCwVSXNXcu~3$Dz0gI?9@;2u_2#@ zMk1rv6H74B>Z%Qp^yAMO_3OI=dSdX4GScR1m}YSs>Y$^L;I zTi&rhA`9yO8Gd95czE~5Z*;UI_+(Tnbic#!FM-y#QNCW}HT-_!q24Wv&V(O1@z7N* zcOWN%Vd9q1g$Dl~UXAKHclg5PPVtE_pAEpl4rmn~`@tyY!Ms1+^v%3uGD7CP@mur$ zHS-o2txpi2!5Ct^`quU2wM=^ntc;`NhN7 zTTxv?bc#e-3*(FMle+YSFHkobzbZOM73qsZi%tE*Mq0`b_`~J0-mb!q3XA`hdJmGW zLPmBV3#^P$VwHs+qeyQ(T|6!h{lnToJRh>=XVfj~6VYkwq(B2N+ zbkUhieW^%40i3$EkN1|gHsVR|XN@>iQ`vLuAMQ^RvTN&$FUjAB#pjT4Q%gI38ljzW znZ%w?^1jgq-H^W`hlP)t@Eyfua0s5RfJSmobspHJOa(W{)3P8wA(5#;k$?DxOc{z_ zsHY4S9JbQ0(A%j0R~f3X_b$m$d`U5xyG_RVoZ*xBKH+(($79OSyIUjjB}-sMWaRP- z*yfjHBsy1Gl;>kIaz=v0fc&eBe2X~^{8qiMZ9VHnWQoKIh;E<6UJ|pj^T`YTi2Y-# z=34~4gFj;IIhtP)|0R5Sr>q|-FV%cE$I7pWlqXWYf^vZ=xD=Q@_ob1*mh~X`8pwJu z4khGhJ>bp$H}HPfV?0H%@*d+6o$B=-wpwp+@!$Lwn;ajwKgj;~DSgO(P=hZ;)<&<_ z!{sthNnZuuNjl8AfUFVQ(N0rsi5YagB+6jUjZxD&Q3k?Z(WcFoksG{nmW;- zPV{?pqCx+n6V zoWGGrTzZ+D*^Ay6d%CNCeK4CMm(@nh zmDWOxA#+ff9_mEmPU13&Gt87Y!4N*v)^ckDe$3^ylRae~2RzZ=lloKgYPc`42war} ztjO-cne5q_C2I4Q*~EPQc(zsKa;5@id%Vg{TTV=Cf~V}{e54lpR7xWH7P7*D&G69( z)o^;cYLJ*b`&vs2@xnf38*#aZ__4=QRDgPQx4 z-!|M>leYVcZuwnZQd=-$Pb9r|pY4La_loX&_QhRTw=e#}T>Q*V=78V)Oh=h*buI4& zf7@ReWGsd6>@Sqxt`GJX>Sxui&&C+t5}Rh9jDCduXAf*Bi1{^s0}Z51>csK{&oLY) zvj==h%#ZNYb{*d&Z_$@!F0G#muMKWpTdFmPoOOVsioM&)v@f4n5lpt^d30ppORPs^ zZVHM=aL$Q0#?X;15OEisjS+u~LC{m@zL_PN-)jse~><$Yp>qHa^F^6!Qh($O0= zd{BaiV)REBvY-Rq(TTstLA?c+i@x9~yM?;MOJs^HlDhry1q;4zh~_(^`MtUqQ4R0Bk1S&SUXP#m&-+z_4SU~CTxSJyZ<2nJJ*ny6Lmwu_82zc- z!99xnnaqXxE9-_bg$-vRJxB0^94RLq!`lS#Cbqq~_6=p~s?6!oeDu$rzN@#{3xp^7 zvOa}Z?I+Za1jak$*{3OgAF=hwTS=baL1NfM{_ z?|Ci8XH6D#ZpJ9<%)xwRjoI%qcBS1>yi3+_9>0rFdagg>%eqw}Ip_}g-=q_V$l@nhK?{