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
70 changes: 66 additions & 4 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ public partial class MainWindow : Window
WpfButton drawerCopyBtn,
WpfButton drawerSendBtn)> _runControls = new();
private readonly Dictionary<string, string> _drawerItemBySession = new();
/// <summary>
/// What each session's drawer TextBox currently holds: which run, the append position
/// it reflects, and how many characters it contains. Lets RefreshRunDrawer append only
/// the new tail rather than re-assigning up to 1MB of text per refresh.
/// </summary>
private readonly Dictionary<string, (RunInstance inst, long position, int length)> _drawerShown = new();
// Per-session sidebar action button panels — kept so SettingsButton_Click
// can flip every row to a new SidebarActionIconsMode without rebuilding sidebar items.
private readonly Dictionary<string, StackPanel> _sidebarActionPanels = new();
Expand Down Expand Up @@ -1189,25 +1195,63 @@ private void RefreshTerminalRunControls(string sessionId)
}
c.chipsStrip.Visibility = instances.Count > 0 ? Visibility.Visible : Visibility.Collapsed;

// Update drawer if a viewed item exists.
RefreshRunDrawer(sessionId);
}

/// <summary>
/// Updates the run-output drawer only — header, text, stop button. This is the part
/// output changes need, and it runs throttled from <c>Runner.OutputChanged</c>.
/// </summary>
private void RefreshRunDrawer(string sessionId)
{
if (!_runControls.TryGetValue(sessionId, out var c)) return;
var vm = _vm.Sessions.FirstOrDefault(s => s.Id == sessionId);
if (vm == null) return;

if (_drawerItemBySession.TryGetValue(sessionId, out var viewedItemId) &&
vm.Runner.GetInstance(viewedItemId) is { } viewedInst)
{
c.drawerHeader.Text = $"{viewedInst.Label} — {DescribeState(viewedInst)}";
c.drawerText.Text = viewedInst.SnapshotOutput();
c.drawerStopBtn.IsEnabled = viewedInst.State == RunState.Running;

// Append only the new tail. Re-assigning the whole buffer (up to 1MB) per
// refresh made the TextBox re-lay-out everything on the UI thread each time.
// Fall back to a full snapshot when the drawer shows a different run, the
// buffer's head was trimmed past what is shown, or the box has grown well
// beyond the buffer cap (AppendText never trims).
string? tail = null;
long position = 0;
if (_drawerShown.TryGetValue(sessionId, out var shown) && ReferenceEquals(shown.inst, viewedInst))
tail = viewedInst.ReadSince(shown.position, out position);

if (tail != null && shown.length + tail.Length <= RunDrawerMaxChars)
{
if (tail.Length > 0) c.drawerText.AppendText(tail);
_drawerShown[sessionId] = (viewedInst, position, shown.length + tail.Length);
}
else
{
string text = viewedInst.SnapshotOutput(out position);
c.drawerText.Text = text;
_drawerShown[sessionId] = (viewedInst, position, text.Length);
}

// Auto-scroll to the end while the run is active.
if (viewedInst.State == RunState.Running)
c.drawerText.ScrollToEnd();
c.drawerStopBtn.IsEnabled = viewedInst.State == RunState.Running;
}
else
{
// Viewed item disappeared (was dismissed). Hide the drawer.
c.drawer.Visibility = Visibility.Collapsed;
_drawerItemBySession.Remove(sessionId);
_drawerShown.Remove(sessionId);
}
}

/// <summary>Drawer text ceiling before a full re-snapshot: 1.5× RunInstance's buffer cap.</summary>
private const int RunDrawerMaxChars = 1_500_000;

private static string DescribeState(RunInstance inst) => inst.State switch
{
RunState.Idle => "idle",
Expand Down Expand Up @@ -4798,7 +4842,22 @@ or nameof(SessionViewModel.WorkingFolder)
_runControls[vm.Id] = (playBtn, chevronBtn, chipsStrip, chipsPanel, drawer,
drawerText, drawerHeader, drawerStopBtn, drawerCopyBtn, drawerSendBtn);

vm.Runner.InstancesChanged += () => Dispatcher.Invoke(() => RefreshTerminalRunControls(vm.Id));
// Never Dispatcher.Invoke from here: state changes can arrive on a PTY thread, and a
// synchronous Invoke parks that thread until the UI thread gets round to it.
vm.Runner.InstancesChanged += () =>
{
if (Dispatcher.CheckAccess()) RefreshTerminalRunControls(vm.Id);
else Dispatcher.BeginInvoke(() => RefreshTerminalRunControls(vm.Id));
};
// Output fires once per 4KB PTY chunk. It used to share the event above, so every
// chunk of a chatty dev server rebuilt the chips and re-assigned the whole drawer
// through a synchronous Invoke. Now: drawer only, at most ~4 times a second, below
// input priority so it never delays a keystroke.
var drawerRefresh = new ThrottledSignal(
TimeSpan.FromMilliseconds(250),
action => Dispatcher.BeginInvoke(action, System.Windows.Threading.DispatcherPriority.Background),
() => RefreshRunDrawer(vm.Id));
vm.Runner.OutputChanged += drawerRefresh.Signal;

// RepoRoot lands async after GitService probes — when AccentColor shifts, repaint
// the top stripe and refresh the active-ring lookup so terminal and sidebar agree.
Expand Down Expand Up @@ -4833,6 +4892,7 @@ private void OnSessionVmClosed(SessionViewModel vm)
}
_runControls.Remove(vm.Id);
_drawerItemBySession.Remove(vm.Id);
_drawerShown.Remove(vm.Id);
_sidebarActionPanels.Remove(vm.Id);
_sidebarRenameActions.Remove(vm.Id);
if (_selectionAnchorId == vm.Id) _selectionAnchorId = null;
Expand Down Expand Up @@ -5127,6 +5187,7 @@ private async Task RestartSessionCoreAsync(SessionViewModel vm, string? launched
}
_runControls.Remove(vm.Id);
_drawerItemBySession.Remove(vm.Id);
_drawerShown.Remove(vm.Id);
_sidebarActionPanels.Remove(vm.Id);
_sidebarRenameActions.Remove(vm.Id);

Expand Down Expand Up @@ -5262,6 +5323,7 @@ private void SleepSession(SessionViewModel vm)
}
_runControls.Remove(vm.Id);
_drawerItemBySession.Remove(vm.Id);
_drawerShown.Remove(vm.Id);
_sidebarActionPanels.Remove(vm.Id);
_sidebarRenameActions.Remove(vm.Id);

Expand Down
55 changes: 45 additions & 10 deletions src/CodeShellManager/Services/RunInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public partial class RunInstance : ObservableObject, IDisposable
private readonly Func<IPseudoTerminal> _ptyFactory;
private readonly StringBuilder _ansiStripped = new();
private readonly object _bufLock = new();
// Characters ever appended, including any since trimmed from the head. Lets the drawer
// append only what is new instead of re-assigning up to 1MB per refresh — see ReadSince.
private long _totalAppended;
private bool _disposed;

public event Action? OutputChanged;
Expand Down Expand Up @@ -78,7 +81,7 @@ public void Start(ShellSession parent)
{
if (_pty != null) throw new InvalidOperationException("Already started — call Dispose() first.");

lock (_bufLock) { _ansiStripped.Clear(); }
lock (_bufLock) { _ansiStripped.Clear(); _totalAppended = 0; }
OutputBuffer = "";
ExitCode = null;
StartedAt = DateTime.Now;
Expand Down Expand Up @@ -152,9 +155,7 @@ private void AppendText(string text)
string snapshot;
lock (_bufLock)
{
_ansiStripped.Append(text);
if (_ansiStripped.Length > MaxBufferChars)
_ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars);
AppendLocked(text);
snapshot = _ansiStripped.ToString();
}
OutputBuffer = snapshot;
Expand Down Expand Up @@ -183,15 +184,19 @@ private void OnPtyData(string text)
// PropertyChanged per chunk on a hot path. OutputBuffer has no consumers
// in the app (only tests) — the app reads SnapshotOutput() on demand.
string stripped = AnsiPattern().Replace(text, "");
lock (_bufLock)
{
_ansiStripped.Append(stripped);
if (_ansiStripped.Length > MaxBufferChars)
_ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars);
}
lock (_bufLock) AppendLocked(stripped);
OutputChanged?.Invoke();
}

/// <summary>Caller holds <see cref="_bufLock"/>.</summary>
private void AppendLocked(string text)
{
_ansiStripped.Append(text);
_totalAppended += text.Length;
if (_ansiStripped.Length > MaxBufferChars)
_ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars);
}

private void OnPtyExited()
{
EndedAt = DateTime.Now;
Expand Down Expand Up @@ -272,6 +277,36 @@ public string SnapshotOutput()
lock (_bufLock) return _ansiStripped.ToString();
}

/// <summary>
/// Snapshots the buffer together with the append position it reflects, so a later
/// <see cref="ReadSince"/> can continue from exactly there. Thread-safe.
/// </summary>
public string SnapshotOutput(out long position)
{
lock (_bufLock)
{
position = _totalAppended;
return _ansiStripped.ToString();
}
}

/// <summary>
/// Text appended after <paramref name="position"/> (a value from an earlier read), or
/// null when the head has since been trimmed past it and the caller must re-snapshot.
/// <paramref name="newPosition"/> is where the next read continues from. Thread-safe.
/// </summary>
public string? ReadSince(long position, out long newPosition)
{
lock (_bufLock)
{
newPosition = _totalAppended;
long headPosition = _totalAppended - _ansiStripped.Length;
if (position < headPosition || position > _totalAppended) return null;
int start = (int)(position - headPosition);
return _ansiStripped.ToString(start, _ansiStripped.Length - start);
}
}

/// <summary>
/// Wraps a single-statement CommandLine for cmd.exe so &amp;&amp;, pipes,
/// redirects, and quoted args all parse correctly. The outer cmd /c
Expand Down
9 changes: 8 additions & 1 deletion src/CodeShellManager/Services/SessionRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ public class SessionRunner : IDisposable
/// <summary>Fires when any instance is added, replaced, or removed, or any state changes.</summary>
public event Action? InstancesChanged;

/// <summary>
/// Fires when any instance produced output — once per PTY chunk, from the PTY reader
/// thread. Kept separate from <see cref="InstancesChanged"/> so the UI can throttle the
/// high-rate output path without delaying chip state changes.
/// </summary>
public event Action? OutputChanged;

public SessionRunner(ShellSession session) { _session = session; }

/// <summary>
Expand Down Expand Up @@ -107,7 +114,7 @@ public void StopAll()
}

private void OnInstanceStateChanged() => InstancesChanged?.Invoke();
private void OnInstanceOutputChanged() => InstancesChanged?.Invoke();
private void OnInstanceOutputChanged() => OutputChanged?.Invoke();

public void Dispose() => StopAll();
}
34 changes: 31 additions & 3 deletions src/CodeShellManager/Services/StateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public class StateService
/// <summary>Serializes concurrent saves — see SaveAsync.</summary>
private static readonly SemaphoreSlim SaveGate = new(1, 1);

/// <summary>Order in which snapshots were taken, and the newest one on disk (read/written under SaveGate).</summary>
private static long _saveSeq, _writtenSeq;

private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Expand Down Expand Up @@ -112,27 +115,52 @@ internal static AppState Normalize(AppState s)
/// file truncated and take the whole workspace with it.
/// </summary>
public async Task SaveAsync(AppState state)
{
// Serialize HERE, on the caller's thread, and do the file work on the pool.
//
// Callers are almost all on the UI thread, and the AppState graph is mutated there
// too, so the snapshot has to be taken there — serializing on another thread could
// walk a session list mid-edit. The I/O is a different matter: the temp write and
// File.Replace used to run on the UI thread as well (no ConfigureAwait, and
// File.Replace is synchronous), and antivirus or a synced folder can make that swap
// take a visible while. Every OSC 9001 prompt hook, window move and git RepoRoot
// landing triggers a save.
long seq = Interlocked.Increment(ref _saveSeq);
byte[] json;
try { json = JsonSerializer.SerializeToUtf8Bytes(state, Options); }
catch (Exception ex) { Log($"save failed: {ex.Message}"); return; }

await Task.Run(() => WriteAsync(json, seq)).ConfigureAwait(false);
}

private static async Task WriteAsync(byte[] json, long seq)
{
// Serialize saves. 29 of the ~32 SaveStateAsync call sites are fire-and-forget,
// so overlapping writes are routine rather than exotic. Without this gate two
// saves race on the same temp file, and one can File.Replace the other's
// half-written content into the live file — manufacturing exactly the torn
// state this method exists to prevent.
await SaveGate.WaitAsync();
await SaveGate.WaitAsync().ConfigureAwait(false);
var path = StatePath;
try
{
// The gate doesn't promise FIFO order, and snapshots are now taken before
// waiting on it — so an older snapshot can reach this point after a newer
// one has already been written. Writing it would roll state back.
if (seq <= _writtenSeq) return;

Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string json = JsonSerializer.Serialize(state, Options);

await File.WriteAllTextAsync(TempPath, json);
await File.WriteAllBytesAsync(TempPath, json).ConfigureAwait(false);

if (File.Exists(path))
// Atomic on NTFS, and rotates the old file into .bak in one step.
// ignoreMetadataErrors: ACL/attribute copy failures must not fail the save.
File.Replace(TempPath, path, BackupPath, ignoreMetadataErrors: true);
else
File.Move(TempPath, path); // first write — nothing to back up or replace

_writtenSeq = seq;
}
catch (Exception ex)
{
Expand Down
26 changes: 18 additions & 8 deletions src/CodeShellManager/Terminal/PseudoTerminal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,18 @@ public void Start(string command, string args, string workingDirectory,
_stdin = new FileStream(_inputWrite!, FileAccess.Write, 4096, false);
_stdout = new FileStream(_outputRead!, FileAccess.Read, 4096, false);

_ = Task.Run(ReadLoopAsync);
// A dedicated thread, not the pool: this read blocks for the whole life of the
// session. On the pool that meant one permanently parked pool thread per PTY —
// 55 of them measured at ~60 sessions, against a pool minimum of one per core.
// Past the minimum the pool only adds threads gradually, so any burst of other
// pool work (git polls, timers, save I/O) queued — including the next read of the
// active pane, which looks exactly like a keystroke that doesn't echo.
// Same reasoning as MonitorExitAsync's RegisterWaitForSingleObject below.
new Thread(ReadLoop)
{
IsBackground = true,
Name = "PTY reader",
}.Start();
_ = Task.Run(MonitorExitAsync);
}

Expand Down Expand Up @@ -353,20 +364,19 @@ private static void Log(string msg)
catch { }
}

private async Task ReadLoopAsync()
private void ReadLoop()
{
var buffer = new byte[4096];
try
{
while (!_cts.IsCancellationRequested)
{
// Anonymous pipes are synchronous — ReadAsync stalls on Windows.
// Use blocking Read() on a thread-pool thread instead.
int read = await Task.Run(() =>
{
try { return _stdout!.Read(buffer, 0, buffer.Length); }
catch { return 0; }
}, _cts.Token);
// Blocking Read() on this dedicated thread instead; it returns 0 when
// the pipe closes (process exit, or Dispose closing the pseudoconsole).
int read;
try { read = _stdout!.Read(buffer, 0, buffer.Length); }
catch { read = 0; }

if (read == 0) break;
// Send raw bytes as Latin-1 so xterm.js can interpret VT sequences correctly
Expand Down
Loading