Skip to content

Commit 0fa0817

Browse files
committed
Settle a pending inline snapshot by member when its line has moved
A queue entry is keyed by source file and line, and the line stops being true the moment an accept inserts a literal above it: a snapshot is several lines of source, so accepting one call site moves every later one in that file. The entries left behind could then never be settled by any later run, because the run reports the line as it is now and the entry still holds the line it was queued at. Verify already carries the member on a patch for exactly this reason, but only the patcher used it. Settle now takes the member and falls back to it when the key matches nothing, resolving the entry within the same file. Only where the member names exactly one entry: a member holding several inline snapshots cannot say which of them a settle was for, and dropping the wrong one loses a pending snapshot outright, so an ambiguous member settles nothing. The member rides the wire as an added optional field, which an owner that predates it reads straight past, so a newer sender still settles by key alone against an older one. Also adds RetireInline, for a call site that is no longer an inline snapshot at all rather than one whose test now passes. It carries no framework, because the statement is not 'this framework passes' but 'there is no snapshot here for any of them', so the owner takes the whole entry instead of stripping one variant and leaving it standing on the others. Verify calls it when a verification resolves as not inline, which is the other half of this fix and lands there.
1 parent 7bb9d17 commit 0fa0817

11 files changed

Lines changed: 221 additions & 22 deletions

File tree

src/DiffEngine.Tests/InlineQueueClientTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,11 @@ int IQueueOwner.Enqueue(InlinePatch patch)
369369
}
370370
}
371371

372-
void IQueueOwner.Settle(string key, string? origin)
372+
void IQueueOwner.Settle(string key, string? origin, string? member)
373373
{
374374
lock (gate)
375375
{
376-
queue = queue.Settle(key, origin);
376+
queue = queue.Settle(key, origin, member);
377377
}
378378
}
379379

src/DiffEngine.Tests/InlineQueueTests.cs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ static InlinePatch Patch(
1010
int line = 42,
1111
string content = "new",
1212
string? framework = null,
13-
string? testName = null) =>
13+
string? testName = null,
14+
string? member = null) =>
1415
new(source, line, "\"old\"", content)
1516
{
1617
Framework = framework,
17-
TestName = testName
18+
TestName = testName,
19+
MemberName = member
1820
};
1921

2022
static InlineApplyResult Fails(InlinePatch patch) =>
@@ -95,6 +97,69 @@ public async Task SettleForAnUnknownKeyReturnsTheSameQueue()
9597
await Assert.That(queue.Settle("nothing")).IsSameReferenceAs(queue);
9698
}
9799

100+
/// <summary>
101+
/// Accepting a snapshot inserts several lines of source, so every later call site in that file
102+
/// moves and the entries queued against them can never be named by their line again. The
103+
/// member is what still points at them.
104+
/// </summary>
105+
[Test]
106+
public async Task SettleFindsAnEntryWhoseLineHasMovedByMember()
107+
{
108+
var queue = InlineQueue.Empty
109+
.Enqueue(Patch(line: 42, member: "MyTest"))
110+
.Settle(InlineKey.For("Sample.cs", 807), null, "MyTest");
111+
112+
await Assert.That(queue.Count).IsEqualTo(0);
113+
}
114+
115+
/// <summary>
116+
/// A member holding several inline snapshots cannot say which of them the settle was for, and
117+
/// dropping the wrong one loses a pending snapshot outright.
118+
/// </summary>
119+
[Test]
120+
public async Task SettleLeavesAnAmbiguousMemberAlone()
121+
{
122+
var queue = InlineQueue.Empty
123+
.Enqueue(Patch(line: 42, member: "MyTest"))
124+
.Enqueue(Patch(line: 48, member: "MyTest"));
125+
126+
await Assert.That(queue.Settle(InlineKey.For("Sample.cs", 807), null, "MyTest"))
127+
.IsSameReferenceAs(queue);
128+
}
129+
130+
[Test]
131+
public async Task SettleDoesNotMatchTheSameMemberInAnotherFile()
132+
{
133+
var queue = InlineQueue.Empty.Enqueue(Patch(source: "Sample.cs", member: "MyTest"));
134+
135+
await Assert.That(queue.Settle(InlineKey.For("Other.cs", 807), null, "MyTest"))
136+
.IsSameReferenceAs(queue);
137+
}
138+
139+
[Test]
140+
public async Task SettleWithoutAMemberStillOnlyMatchesTheKey()
141+
{
142+
var queue = InlineQueue.Empty.Enqueue(Patch(line: 42, member: "MyTest"));
143+
144+
await Assert.That(queue.Settle(InlineKey.For("Sample.cs", 807))).IsSameReferenceAs(queue);
145+
}
146+
147+
/// <summary>
148+
/// A call site that is no longer inline at all carries no framework, because the statement is
149+
/// not "this framework now passes" but "there is no snapshot here for any of them". So it
150+
/// takes the whole entry, not one variant of it.
151+
/// </summary>
152+
[Test]
153+
public async Task SettleWithoutAnOriginTakesEveryVariant()
154+
{
155+
var queue = InlineQueue.Empty
156+
.Enqueue(Patch(content: "eight", framework: "net8.0", member: "MyTest"))
157+
.Enqueue(Patch(content: "nine", framework: "net9.0", member: "MyTest"))
158+
.Settle(InlineKey.For("Sample.cs", 807), null, "MyTest");
159+
160+
await Assert.That(queue.Count).IsEqualTo(0);
161+
}
162+
98163
[Test]
99164
public async Task AcceptAppliesAndRemoves()
100165
{

src/DiffEngine.Tests/ViewerProtocolTests.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,31 @@ public async Task SettleCarriesTheOriginInTheBody()
252252
await Assert.That(message.Body).IsEqualTo("net9.0");
253253
}
254254

255+
[Test]
256+
public async Task SettleCarriesTheMember()
257+
{
258+
var payload = new ViewerMessage(ViewerVerb.Settle, InlineKey.For("tests.cs", 42), "net9.0", "MyTest")
259+
.Build();
260+
261+
await Assert.That(ViewerMessage.TryParse(payload, out var message)).IsTrue();
262+
await Assert.That(message!.Key).IsEqualTo("tests.cs|42");
263+
await Assert.That(message.Body).IsEqualTo("net9.0");
264+
await Assert.That(message.Member).IsEqualTo("MyTest");
265+
}
266+
267+
/// <summary>
268+
/// The member is an added field, so a payload written before it existed still reads, which is
269+
/// what lets a newer sender talk to an older owner.
270+
/// </summary>
271+
[Test]
272+
public async Task SettleWithoutAMemberParses()
273+
{
274+
var payload = new ViewerMessage(ViewerVerb.Settle, InlineKey.For("tests.cs", 42), "net9.0").Build();
275+
276+
await Assert.That(ViewerMessage.TryParse(payload, out var message)).IsTrue();
277+
await Assert.That(message!.Member).IsNull();
278+
}
279+
255280
[Test]
256281
public async Task AFullListingCarriesThePrimaryOrigins()
257282
{
@@ -469,7 +494,7 @@ class FakeOwner((bool ok, string? message) act) :
469494

470495
public int Enqueue(InlinePatch patch) => 1;
471496

472-
public void Settle(string key, string? origin)
497+
public void Settle(string key, string? origin, string? member)
473498
{
474499
}
475500

src/DiffEngine/DiffRunner_Inline.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,40 @@ public static async Task<InlineResult> AddInlineAsync(InlinePatch patch, Cancel
7878
/// conflicted entry; the other framework's differing content stays pending.
7979
/// </para>
8080
/// </summary>
81-
public static void SettleInline(string sourceFile, int line)
81+
/// <param name="memberName">
82+
/// The member the call site sits in. Optional, and only used where the line no longer names
83+
/// the entry, which is what happens once an accept inserts a literal above it.
84+
/// </param>
85+
public static void SettleInline(string sourceFile, int line, string? memberName = null)
8286
{
8387
if (Disabled)
8488
{
8589
return;
8690
}
8791

88-
ViewerClient.TrySend(new(ViewerVerb.Settle, InlineKey.For(sourceFile, line), RuntimeMoniker.Current));
92+
ViewerClient.TrySend(
93+
new(ViewerVerb.Settle, InlineKey.For(sourceFile, line), RuntimeMoniker.Current, memberName));
94+
}
95+
96+
/// <summary>
97+
/// Drops a pending inline snapshot for a call site that is no longer an inline snapshot at
98+
/// all: the verification opted out, the global switch declined it, or its literal outgrew the
99+
/// size limit and moved to a file.
100+
/// <para>
101+
/// Unlike <see cref="SettleInline" /> this carries no framework, because the statement is not
102+
/// "this framework now passes" but "there is no inline snapshot here for any of them". A
103+
/// per-framework settle would strip one label and leave the entry standing on the others,
104+
/// pending against a call site that can never produce a snapshot again.
105+
/// </para>
106+
/// </summary>
107+
public static void RetireInline(string sourceFile, int line, string? memberName = null)
108+
{
109+
if (Disabled)
110+
{
111+
return;
112+
}
113+
114+
ViewerClient.TrySend(new(ViewerVerb.Settle, InlineKey.For(sourceFile, line), null, memberName));
89115
}
90116

91117
static InlineResult CheckInline()

src/DiffEngine/Inline/InlineQueue.cs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,16 +215,28 @@ static PendingInline Fold(PendingInline entry, InlinePatch patch)
215215
public InlineQueue Settle(string key) =>
216216
Settle(key, null);
217217

218+
public InlineQueue Settle(string key, string? origin) =>
219+
Settle(key, origin, null);
220+
218221
/// <summary>
219222
/// Origin-scoped settle. A framework that starts passing removes only its own label; a variant
220223
/// with no labels left is dropped, and the entry goes when its last variant does, so the other
221224
/// framework's still-failing content stays reviewable. A null origin, or an entry whose
222225
/// variants are all unlabeled, settles the whole entry.
223226
/// </summary>
224-
public InlineQueue Settle(string key, string? origin)
227+
/// <param name="member">
228+
/// The member the settled call site sits in, used only when <paramref name="key" /> matches
229+
/// nothing. See <see cref="FindByMember" />.
230+
/// </param>
231+
public InlineQueue Settle(string key, string? origin, string? member)
225232
{
226233
var items = Items.ToList();
227234
var index = items.FindIndex(_ => _.Key == key);
235+
if (index < 0)
236+
{
237+
index = FindByMember(items, key, member);
238+
}
239+
228240
if (index < 0)
229241
{
230242
return this;
@@ -271,6 +283,62 @@ public InlineQueue Settle(string key, string? origin)
271283
return new(items);
272284
}
273285

286+
/// <summary>
287+
/// The entry a settle was for when its key names no entry, found by the member instead.
288+
/// </summary>
289+
/// <remarks>
290+
/// A key names a line, and a line stops being true the moment an accept inserts a literal
291+
/// above it — a snapshot is several lines of source, so accepting one call site moves every
292+
/// later one in the file. The entries left behind could then never be settled by any later
293+
/// run: the run reports the line as it is now, and the entry still holds the line it was
294+
/// queued at. The member survives that, which is why a patch carries it and why the patcher
295+
/// already locates call sites by it.
296+
/// <para>
297+
/// Only when the member names exactly one entry in that file. A member holding several inline
298+
/// snapshots cannot say which of them a settle was for, and dropping the wrong one loses a
299+
/// pending snapshot outright — so an ambiguous member settles nothing, leaving the entries as
300+
/// they were.
301+
/// </para>
302+
/// </remarks>
303+
static int FindByMember(List<PendingInline> items, string key, string? member)
304+
{
305+
if (string.IsNullOrEmpty(member))
306+
{
307+
return -1;
308+
}
309+
310+
var file = FileOf(key);
311+
var found = -1;
312+
for (var index = 0; index < items.Count; index++)
313+
{
314+
var entry = items[index];
315+
if (entry.Patch.MemberName != member ||
316+
FileOf(entry.Key) != file)
317+
{
318+
continue;
319+
}
320+
321+
if (found >= 0)
322+
{
323+
return -1;
324+
}
325+
326+
found = index;
327+
}
328+
329+
return found;
330+
}
331+
332+
/// <summary>
333+
/// The file half of a key. Taken off the key rather than off the patch, so both sides are
334+
/// case folded the way <see cref="InlineKey.For" /> folds them for this platform.
335+
/// </summary>
336+
static string FileOf(string key)
337+
{
338+
var separator = key.LastIndexOf('|');
339+
return separator < 0 ? key : key[..separator];
340+
}
341+
274342
public InlineQueue Discard(string key, out string? message)
275343
{
276344
var entry = Find(key);

src/DiffEngine/Protocol/IQueueOwner.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ interface IQueueOwner
1313
int Enqueue(InlinePatch patch);
1414

1515
/// <summary>
16-
/// Drop the entry for a key whose test started passing — or, with an origin, just that
17-
/// framework's variant of it. An unknown key is a no-op, because the entry being gone is the
16+
/// Drop the entry for a key whose test started passing, or is no longer an inline snapshot at
17+
/// all — or, with an origin, just that framework's variant of it. An unknown key falls back to
18+
/// <paramref name="member" />, and is otherwise a no-op, because the entry being gone is the
1819
/// goal state.
1920
/// </summary>
20-
void Settle(string key, string? origin);
21+
void Settle(string key, string? origin, string? member);
2122

2223
/// <summary>
2324
/// Track a pending file move, replacing the entry for the same received file — a re-run

src/DiffEngine/Protocol/ViewerMessage.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ namespace DiffEngine;
55
/// the verbs that act on one; <paramref name="Body"/> carries an <see cref="InlinePatchFile"/>
66
/// payload for <see cref="ViewerVerb.Inline"/>.
77
/// </summary>
8-
record ViewerMessage(ViewerVerb Verb, string? Key = null, string? Body = null)
8+
/// <param name="Member">
9+
/// The member the call site sits in, on a <see cref="ViewerVerb.Settle"/>. A fallback for the key,
10+
/// which names a line and so stops being true once an accept inserts a literal above it. Optional,
11+
/// and read past by an owner that predates it, so an older one still settles by key alone.
12+
/// </param>
13+
record ViewerMessage(ViewerVerb Verb, string? Key = null, string? Body = null, string? Member = null)
914
{
1015
public string Build()
1116
{
1217
var builder = new StringBuilder($"version: {ViewerPayload.Version}\n");
1318
builder.Append($"verb: {Verb.ToString().ToLowerInvariant()}\n");
1419
ViewerPayload.Append(builder, "key", Key);
1520
ViewerPayload.Append(builder, "body", Body);
21+
ViewerPayload.Append(builder, "member", Member);
1622
return builder.ToString();
1723
}
1824

@@ -28,6 +34,7 @@ public static bool TryParse(string text, [NotNullWhen(true)] out ViewerMessage?
2834
ViewerVerb? verb = null;
2935
string? key = null;
3036
string? body = null;
37+
string? member = null;
3138
foreach (var (name, value) in lines)
3239
{
3340
switch (name)
@@ -55,6 +62,13 @@ public static bool TryParse(string text, [NotNullWhen(true)] out ViewerMessage?
5562
return false;
5663
}
5764

65+
continue;
66+
case "member":
67+
if (!ViewerPayload.TryDecode(value, out member))
68+
{
69+
return false;
70+
}
71+
5872
continue;
5973
default:
6074
// Unknown fields are ignored so a newer client can add one without breaking
@@ -68,7 +82,7 @@ public static bool TryParse(string text, [NotNullWhen(true)] out ViewerMessage?
6882
return false;
6983
}
7084

71-
message = new(verb.Value, key, body);
85+
message = new(verb.Value, key, body, member);
7286
return true;
7387
}
7488
}

src/DiffEngine/Protocol/ViewerMessageHandler.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public static ViewerResponse Handle(IQueueOwner owner, ViewerMessage message)
1616
case ViewerVerb.Inline:
1717
return Inline(owner, message.Body);
1818
case ViewerVerb.Settle:
19-
return Settle(owner, message.Key, message.Body);
19+
return Settle(owner, message.Key, message.Body, message.Member);
2020
case ViewerVerb.Move:
2121
return Move(owner, message.Key, message.Body);
2222
case ViewerVerb.Delete:
@@ -74,14 +74,14 @@ static ViewerResponse Inline(IQueueOwner owner, string? body)
7474
return ViewerResponse.Success($"Queued {owner.Enqueue(patch)}");
7575
}
7676

77-
static ViewerResponse Settle(IQueueOwner owner, string? key, string? origin)
77+
static ViewerResponse Settle(IQueueOwner owner, string? key, string? origin, string? member)
7878
{
7979
if (key is null)
8080
{
8181
return ViewerResponse.Error("Settle requires a key");
8282
}
8383

84-
owner.Settle(key, origin);
84+
owner.Settle(key, origin, member);
8585
return ViewerResponse.Success();
8686
}
8787

src/DiffEngineTray/OwnedInlineHost.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,11 @@ int IQueueOwner.Enqueue(InlinePatch patch)
170170
return count;
171171
}
172172

173-
void IQueueOwner.Settle(string key, string? origin)
173+
void IQueueOwner.Settle(string key, string? origin, string? member)
174174
{
175175
lock (gate)
176176
{
177-
var settled = queue.Settle(key, origin);
177+
var settled = queue.Settle(key, origin, member);
178178
if (ReferenceEquals(settled, queue))
179179
{
180180
return;

src/DiffEngineViewer/Ipc/MessageHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ int IQueueOwner.Enqueue(InlinePatch patch)
2727
return count;
2828
}
2929

30-
void IQueueOwner.Settle(string key, string? origin) =>
31-
host.Mutate(_ => ViewerSession.Settle(_, key, origin));
30+
void IQueueOwner.Settle(string key, string? origin, string? member) =>
31+
host.Mutate(_ => ViewerSession.Settle(_, key, origin, member));
3232

3333
/// <summary>
3434
/// The files are read here, on the listener thread, so the session stays IO free — the same

0 commit comments

Comments
 (0)