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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/XTerm.NET.Tests/ShellIntegrationMarkAnchorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,41 @@ public void With_no_prompts_there_is_nothing_to_jump_to()
Assert.False(t.TryFindPreviousPrompt(t.Buffer.Lines.Length, out _));
Assert.False(t.TryFindNextPrompt(-1, out _));
}

/// <summary>
/// The list <see cref="BufferLine.Marks"/> hands back is a snapshot, so a host reading it can
/// finish reading it. A host does this from its UI thread while the parser is still writing on
/// whatever thread drains the pty; against a live list that append throws
/// <see cref="InvalidOperationException"/> mid-enumeration.
///
/// <para>Written without threads on purpose: the property that fixes the race is
/// "an append does not touch a list already handed out", and that is exactly testable.
/// A thread test would only reproduce it sometimes and would pass on the broken code.</para>
/// </summary>
[Fact]
public void A_mark_list_stays_enumerable_while_more_marks_arrive()
{
var t = Fresh();
t.Write(Mark("A"));

using var reading = MarksOn(t, 0).GetEnumerator();
Assert.True(reading.MoveNext());

t.Write(Mark("B"));

Assert.False(reading.MoveNext());
}

[Fact]
public void A_mark_list_already_handed_out_does_not_grow()
{
var t = Fresh();
t.Write(Mark("A"));
var handedOut = MarksOn(t, 0);

t.Write(Mark("B"));

Assert.Single(handedOut);
Assert.Equal(2, MarksOn(t, 0).Count);
}
}
38 changes: 35 additions & 3 deletions src/XTerm.NET/Buffer/BufferLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ public class BufferLine : IEnumerable<BufferCell>
/// <summary>
/// Shell-integration marks on this line, or null — which is every line that is not a prompt.
/// </summary>
private List<LineMark>? _marks;
/// <remarks>
/// Volatile because <see cref="AddMark"/> publishes a new list rather than appending to this
/// one, and a reader on another thread must see the list fully built before it sees the
/// reference. Without the release/acquire pair that is not guaranteed on a weak memory model.
/// </remarks>
private volatile List<LineMark>? _marks;

/// <summary>
/// OSC 8 link spans on this line, or null — which is nearly every line.
Expand Down Expand Up @@ -471,6 +476,13 @@ public bool ClearImages()
/// <summary>
/// The shell-integration marks on this line, in the order they were emitted.
/// </summary>
/// <remarks>
/// A snapshot: the list handed back is never mutated afterwards, because <see cref="AddMark"/>
/// publishes a new one instead of appending to it. That is what makes this readable from a
/// thread other than the one writing to the terminal, which is what shell integration actually
/// does — a host asks where the prompt is while the parser is still emitting marks, and
/// enumerating a live <see cref="List{T}"/> across an append throws.
/// </remarks>
public IReadOnlyList<LineMark> Marks
=> (IReadOnlyList<LineMark>?)_marks ?? Array.Empty<LineMark>();

Expand All @@ -480,11 +492,31 @@ public IReadOnlyList<LineMark> Marks
/// <remarks>
/// A line collects several: a prompt emits A and then B, and a command that produces no output
/// finishes on the same line it started on.
///
/// <para>Costs one small allocation per mark, which the snapshot in <see cref="Marks"/> is
/// worth: marks arrive a handful of times per command, not per cell, so this is nowhere near
/// the print path.</para>
/// </remarks>
internal void AddMark(LineMark mark)
{
_marks ??= new List<LineMark>(1);
_marks.Add(mark);
// Copy on write, not Add — see Marks. Whoever already holds the published list keeps
// enumerating it unharmed; the reference swap is atomic, so a reader sees the old list or
// the new one and never a half-built one. Sized for the result so the Add below cannot
// reallocate on top of the copy.
var current = _marks;
List<LineMark> next;
if (current is null)
{
next = new List<LineMark>(1);
}
else
{
next = new List<LineMark>(current.Count + 1);
next.AddRange(current);
}

next.Add(mark);
_marks = next;
}

/// <summary>
Expand Down
Loading