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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ Nightly builds of `master` are on

### 🐛 Fixes

- **A background-service start failure no longer carries part of the PTY handoff ticket.** The
contender's stderr tail redacts the ticket even where the 8 KiB cut or the end of the pipe splits
it, so no fragment of it reaches `OpenCodeServerException` messages.
- **Caller cancellation preserves your token across public async doors:** HTTP, SSE, cursor
traversal, both terminal families, and `OpenCodeServer.StartAsync`, `DiscoverAsync`, `EnsureAsync`,
and `StopAsync`. `OperationCanceledException.CancellationToken` is the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ internal sealed class ServiceContender : IServiceContender
/// <summary>What a redacted handoff ticket reads as in diagnostics.</summary>
private const string RedactedMarker = "[redacted]";

private static readonly byte[] RedactedMarkerBytes = Encoding.UTF8.GetBytes(RedactedMarker);

/// <summary>Windows' still-running sentinel for <c>GetExitCodeProcess</c>.</summary>
private const uint StillActive = 259;

Expand All @@ -47,7 +49,14 @@ internal sealed class ServiceContender : IServiceContender
private readonly Lock _gate = new();
private readonly Stream _stderr;
private readonly SafeProcessHandle? _process;
private readonly string? _redact;
/// <summary>The handoff ticket's UTF-8 bytes, matched in the raw tail so a cut cannot split a match.</summary>
private readonly byte[]? _redact;

/// <summary>
/// The raw bytes retained: the rendered 8 KiB, plus one ticket length short of it when a ticket
/// is redacted, so a ticket the 8 KiB edge cuts through is still found whole.
/// </summary>
private readonly int _retainLimit;
private byte[] _tail = [];

private bool _released;
Expand All @@ -65,7 +74,8 @@ public ServiceContender(int processId, Stream stderr, SafeProcessHandle? process
ProcessId = processId;
_stderr = stderr;
_process = process;
_redact = string.IsNullOrEmpty(redact) ? null : redact;
_redact = string.IsNullOrEmpty(redact) ? null : Encoding.UTF8.GetBytes(redact);
_retainLimit = StderrLimit + (_redact is null ? 0 : _redact.Length - 1);

// Hot until the first read suspends: the drain owns no caller, only the pipe, and every
// fault it can see is folded into the error slot — nothing escapes unobserved.
Expand Down Expand Up @@ -199,31 +209,71 @@ private string RenderStderrLocked()
return string.Empty;
}

var text = Encoding.UTF8.GetString(_tail);
if (_redact is { Length: > 0 } ticket)
{
text = RedactTicket(text, ticket);
}

var start = Math.Max(0, _tail.Length - StderrLimit);
var text = _redact is null
? Encoding.UTF8.GetString(_tail, start, _tail.Length - start)
: RedactTicket(_tail, start, _redact);
return text.Trim();
}

/// <summary>
/// Ordinal ticket redaction without the framework's comparison overload, which the downlevel
/// targets do not carry: split at every occurrence and rejoin over the marker.
/// Renders the bytes from <paramref name="start"/> with every byte a handoff ticket covers
/// replaced, each covered run by one marker, before any decoding. Every occurrence counts,
/// overlapping ones too; a ticket the 8 KiB edge cuts through is matched whole in the retained
/// bytes before the edge, and a ticket the pipe has not finished writing ends the tail as a
/// prefix and is covered as one.
/// </summary>
private static string RedactTicket(string text, string ticket)
private static string RedactTicket(byte[] tail, int start, byte[] ticket)
{
var covered = new bool[tail.Length - start];
var searchFrom = Math.Max(0, start - (ticket.Length - 1));
while (searchFrom < tail.Length && tail.AsSpan(searchFrom).IndexOf(ticket) is >= 0 and var offset)
{
var index = searchFrom + offset;
Cover(covered, start, index, index + ticket.Length);
searchFrom = index + 1;
}

var unfinished = UnfinishedTicketLength(tail, ticket);
Cover(covered, start, tail.Length - unfinished, tail.Length);

using var rendered = new MemoryStream(covered.Length);
for (var position = 0; position < covered.Length; position++)
{
if (!covered[position])
{
rendered.WriteByte(tail[start + position]);
}
else if (position == 0 || !covered[position - 1])
{
rendered.Write(RedactedMarkerBytes, 0, RedactedMarkerBytes.Length);
}
}

return Encoding.UTF8.GetString(rendered.GetBuffer(), 0, (int)rendered.Length);
}

/// <summary>Marks the tail bytes [<paramref name="from"/>, <paramref name="to"/>) that fall inside the window at <paramref name="start"/>.</summary>
private static void Cover(bool[] covered, int start, int from, int to)
{
var redacted = new StringBuilder(text.Length);
var remainder = text;
while (remainder.IndexOf(ticket, StringComparison.Ordinal) is >= 0 and var index)
for (var index = Math.Max(from, start); index < to; index++)
{
_ = redacted.Append(remainder, 0, index).Append(RedactedMarker);
remainder = remainder[(index + ticket.Length)..];
covered[index - start] = true;
}
}

/// <summary>The length of the longest proper ticket prefix the retained tail ends with; zero when none.</summary>
private static int UnfinishedTicketLength(byte[] tail, byte[] ticket)
{
for (var length = Math.Min(ticket.Length - 1, tail.Length); length > 0; length--)
{
if (tail.AsSpan().EndsWith(ticket.AsSpan(0, length)))
{
return length;
}
}

_ = redacted.Append(remainder);
return redacted.ToString();
return 0;
}

private string WithStderrLocked(string message)
Expand All @@ -233,9 +283,10 @@ private string WithStderrLocked(string message)
}

/// <summary>
/// Keeps the final 8 KiB of the pipe the way upstream's <c>onStderr</c> does: the chunk's own
/// last 8 KiB, behind as much of the previous tail as still fits. A released contender still
/// drains, into nothing.
/// Keeps the end of the pipe the way upstream's <c>onStderr</c> does: the chunk's own last
/// bytes, behind as much of the previous tail as still fits. The limit is 8 KiB, plus the
/// ticket slack when a ticket is redacted; rendering still shows only the final 8 KiB. A
/// released contender still drains, into nothing.
/// </summary>
private void AppendTailLocked(byte[] buffer, int count)
{
Expand All @@ -244,8 +295,8 @@ private void AppendTailLocked(byte[] buffer, int count)
return;
}

var kept = Math.Min(count, StderrLimit);
var carried = Math.Min(_tail.Length, StderrLimit - kept);
var kept = Math.Min(count, _retainLimit);
var carried = Math.Min(_tail.Length, _retainLimit - kept);
var next = new byte[carried + kept];
Buffer.BlockCopy(_tail, _tail.Length - carried, next, 0, carried);
Buffer.BlockCopy(buffer, count - kept, next, carried, kept);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Text;
using OpenCode.Sdk.Internal;

namespace OpenCode.Sdk.Tests.Hosting.BackgroundService.Contender;

/// <summary>
/// The contender's retained stderr as diagnostics read it. The stderr is an in-memory stream, which
/// the drain consumes to its end inside the constructor, so every verdict is decided before the
/// first assertion. The pid is this process, never a child of it: a POSIX exit poll reads that as
/// reaped elsewhere, and a Windows poll has no handle to read.
/// </summary>
public sealed class ServiceContenderTests
{
/// <summary>The pinned client's <c>stderrLimit</c> (<c>service-contender.ts</c>).</summary>
private const int StderrLimit = 8 * 1024;

private const string Ticket = "{\"source\":\"sdk-test\",\"secret\":\"0123456789abcdef0123456789abcdef\"}";

[Test]
public async Task Stderr_Should_Redact_Every_Whole_Ticket()
{
using var contender = Contender("before " + Ticket + " middle " + Ticket + " after");

await Assert.That(contender.Stderr).IsEqualTo("before [redacted] middle [redacted] after");
}

/// <summary>
/// The 8 KiB edge falls inside the ticket: one byte, a few bytes, or all but one of its 65
/// bytes lie before the edge.
/// </summary>
[Test]
[Arguments(1)]
[Arguments(13)]
[Arguments(64)]
public async Task Stderr_Should_Redact_A_Ticket_The_Tail_Cut_Splits(int bytesBeforeTheCut)
{
var after = new string('b', StderrLimit - (Ticket.Length - bytesBeforeTheCut));
using var contender = Contender(new string('a', 100) + Ticket + after);

await Assert.That(contender.Stderr).IsEqualTo("[redacted]" + after);
}

[Test]
public async Task Stderr_Should_Redact_A_Ticket_The_Pipe_Ended_Inside()
{
using var contender = Contender("starting " + Ticket[..20]);

await Assert.That(contender.Stderr).IsEqualTo("starting [redacted]");
}

/// <summary>A ticket whose end repeats its start: the occurrences overlap and cover one run.</summary>
[Test]
public async Task Stderr_Should_Redact_Overlapping_Tickets_As_One_Run()
{
using var contender = Contender("x ababab y", redact: "abab");

await Assert.That(contender.Stderr).IsEqualTo("x [redacted] y");
}

/// <summary>A ticket longer than the rendered 8 KiB that the pipe ended inside: no byte of it shows.</summary>
[Test]
public async Task Stderr_Should_Redact_An_Unfinished_Ticket_Longer_Than_The_Tail()
{
var body = new string('x', StderrLimit);
using var contender = Contender("start A" + body, redact: "A" + body + "Z");

await Assert.That(contender.Stderr).IsEqualTo("[redacted]");
}

[Test]
public async Task Stderr_Should_Keep_The_Final_8_KiB_Unchanged_Without_A_Ticket()
{
var kept = new string('k', StderrLimit);
using var contender = Contender(new string('d', 100) + kept, redact: null);

await Assert.That(contender.Stderr).IsEqualTo(kept);
}

private static ServiceContender Contender(string stderr, string? redact = Ticket) =>
new(Environment.ProcessId, new MemoryStream(Encoding.UTF8.GetBytes(stderr)), process: null, redact);
}
Loading