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
96 changes: 92 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js)
| `StateService` | JSON persistence → `%AppData%/CodeShellManager/state.json`. Writes are **atomic**: serialize to `.tmp`, then `File.Replace` into place, rotating the previous file to `.bak`. `LoadAsync` falls back to `.bak` when the primary won't parse, and logs every step to `crash.log` rather than silently starting empty. A static `SemaphoreSlim` serializes saves — 29 of the ~32 `SaveStateAsync` call sites are fire-and-forget, and overlapping saves would otherwise race on the shared temp file. See issue #88. |
| `SearchService` | SQLite FTS5 search of all terminal output; also owns the `project_notes` table |
| `ColorService` | FNV-1a hash of folder path → 12-color palette |
| `GitService` | Async `git branch --show-current` + `git status --porcelain` |
| `GitService` | Async `git branch --show-current` + `git status --porcelain`. **Every await is `ConfigureAwait(false)` and `RunGitFullAsync` is `Task.Run`-wrapped — do not "simplify" either away.** See "Never spawn a process on the UI thread" below |
| `GitRepoWatcher` | `FileSystemWatcher` on a repo's `.git/HEAD` + `index`, debounced 400ms. Lets git state refresh on checkout/commit/stage instead of by polling. Resolves the `gitdir:` indirection so a linked worktree watches its own HEAD, not the main repo's. Returns null outside a repo — callers treat that as "poll only", not an error |
| `AlertDetector` | Pattern matching for Claude prompts/approvals |
| `CommandPresetsService` | Launch presets + in-session shortcuts |
| `ClaudeSessionService` | Detects `claude` invocations; finds last `--resume` session id under `~/.claude/projects/` |
Expand Down Expand Up @@ -135,6 +136,58 @@ tests/

The accent comes from the **live VM**, not the `Border.Tag` stashed at build time: `RepoRoot` is populated asynchronously by `GitService` and `AccentColor` changes when it lands, so a cached Tag goes stale and stops matching the sidebar ring. The Tag survives only as a fallback. `SetBorderColor` also assigns only when the colour actually differs — it previously allocated a fresh brush and reassigned every pane on every call, which was invisible at one call per switch and a visible flicker storm when something called it rapidly.

## Never spawn a process on the UI thread

An `async` method runs everything **before its first `await` synchronously on the calling
thread**. `GitService.RunGitFullAsync` had `Process.Start` there, and the git poll chain
starts in `SessionViewModel`'s constructor — which `MainWindow.LaunchSessionAsync` runs on
the UI thread. The WPF `SynchronizationContext` was therefore captured, every continuation
returned to the UI thread, and git process creation happened *on* it.

At 47 sessions polling every 10s that was ~94 synchronous `Process.Start` calls per cycle on
the UI thread. Traced live (issue #70): **42 UI stalls, worst 28.5s**, with foreground panes
accumulating output and flushing it in one blob — `dispatcher-latency=12781ms len=3350`. That
is the "I type and nothing appears, then it all appears at once" report.

Measured on this hardware, and the numbers are the argument:

| | |
|---|---|
| `cmd /c exit` | 21 ms — Windows baseline process creation |
| `git --version` | 42 ms — git startup, **zero** repo access |
| `git branch --show-current` | 41 ms — indistinguishable from doing nothing |
| `git status --porcelain` | 48–65 ms — only 6–23 ms of it is the tree walk |
| `Process.Start` alone | ~15 ms — the part that lands on the calling thread |

**~85-90% of any git call is process startup, not git.** There is no faster query to switch
to, so the only fixes are to not be on the UI thread and to not spawn at all.

Three rules, all load-bearing:

1. **`GitService` must never depend on the caller's thread.** `RunGitFullAsync` is
`Task.Run`-wrapped so `Process.Start` cannot run inline, and every await is
`ConfigureAwait(false)` so no continuation can climb back. Both halves are needed:
`ConfigureAwait` alone would not have moved `Process.Start`.
2. **Long-lived loops started from the UI thread must be `Task.Run`-wrapped.**
`SessionViewModel`'s constructor does this for the git poll. A bare `_ = SomeAsync()` in a
constructor that runs on the UI thread silently pins the whole chain to it.
3. **Don't poll what you can watch.** `GitRepoWatcher` catches checkout/commit/stage
immediately; the poll only survives for working-tree edits, which dirty `status` without
touching `.git`. Foreground sessions poll at 10s, background at 120s, and switching to a
pane forces an immediate refresh via `SessionViewModel.IsForegroundSession`.

Guarded by `tests/CodeShellManager.Tests/GitServiceThreadingTests.cs`, which calls
`GitService` from a thread whose `SynchronizationContext` never runs work: if any await
captures it the call never completes and the test times out. All three tests fail against
the pre-fix code — verify that still holds before trusting a change here.

**`GitBranch` / `GitIsDirty` / `GitInfoLoaded` are hand-written properties, not
`[ObservableProperty]`.** They share one notification, `GitInfoVersion`, because three
generated setters meant three PropertyChanged events per poll per session — 141 sidebar
rebuilds per cycle at 47 sessions. `ApplyGitInfo` also returns early when the result is
unchanged, which is almost always: a branch changes maybe once an hour. Bind to
`GitInfoVersion`; the individual properties raise nothing of their own.

## What makes a session "active"

`MainViewModel.ActiveSession` drives the highlight, the dispatcher priority of terminal output (`TerminalBridge.IsForeground`, issue #70), and every `ActiveSession`-scoped command (`Ctrl+W`, `F5`, the run buttons). Three things set it:
Expand Down Expand Up @@ -518,14 +571,49 @@ The tag value overrides the csproj `<Version>` at publish time (`-p:Version=` fl

```bash
# 1. wait for CI / Release to finish and the GitHub Release to exist
# 2. then dispatch BOTH mirrors by hand
# 2. then dispatch the mirrors by hand
gh workflow run winget.yml -f tag=vX.Y.Z
gh workflow run chocolatey.yml -f tag=vX.Y.Z
# 3. watch both — they fail independently of CI and nothing else will tell you
gh workflow run chocolatey.yml -f tag=vX.Y.Z # ONLY if not blocked — see below
# 3. watch them — they fail independently of CI and nothing else will tell you
```

To make it genuinely automatic, CI / Release would have to create the Release with a PAT rather than `GITHUB_TOKEN`.

**Chocolatey is currently blocked on moderation — do not dispatch it.** The v0.5.0
submission is still awaiting *human* review on community.chocolatey.org. Automated
verification passes (last resubmission 03 Sep 2026, the #112 icon-CDN + WebView2 round), but
until a moderator approves it, newer versions cannot be submitted on top of it. Dispatching
`chocolatey.yml` for v0.6.0 or v0.7.0 does not queue them behind the review — it fails.

Two consequences worth knowing before reading the numbers:

- `community.chocolatey.org/packages/codeshellmanager` still serves **v0.5.0**, and will
keep doing so however many tags get pushed here.
- A package under moderation is not listed in search and cannot be installed without an
explicit `--version`, so its download counter reflects the moderation pipeline more than
it reflects users.

Check the package page for the "awaiting moderation" banner before dispatching. Once it
clears, the backlog is submitted per tag.

### Download counts: GitHub's number already contains the other two

There is no per-channel breakdown, and it is easy to add the three up and get a wrong total.
Both mirrors resolve to **the GitHub Release MSI asset**: the winget manifest's
`InstallerUrl` points straight at it, and `.chocolatey/tools/chocolateyinstall.ps1` has its
`__URL64__` placeholder substituted with the same URL at pack time.

So the MSI `download_count` on the GitHub Release is the **union** of GitHub-direct, winget
and Chocolatey installs — not the GitHub-only slice — and Chocolatey's own counter is a
subset of it, not an addition. It also includes the winget-pkgs validation pipeline's
download of each submitted MSI.

Winget publishes no install statistics at all (Microsoft doesn't expose them, and the
unofficial `api.winget.run` index doesn't carry this package). Separating the channels would
mean publishing a byte-identical second MSI per release and pointing `winget.yml`'s
`installers-regex` at it — same SHA256, so winget-pkgs validation is unaffected — and that
only works going forward.

### winget: the `CreateRef` error names the wrong culprit

`winget.yml` submits the signed MSI to microsoft/winget-pkgs as `UmageAI.CodeShellManager` via [winget-releaser](https://github.com/vedantmgoyal9/winget-releaser). Needs `WINGET_TOKEN` — a **classic** PAT (fine-grained tokens are unsupported) with **both** `public_repo` and `workflow`.
Expand Down
40 changes: 39 additions & 1 deletion src/CodeShellManager/Assets/terminal-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,49 @@
window.chrome.webview.postMessage(JSON.stringify({ type: 'resize', cols, rows }));
});

// ── Page-side diagnostics (issue #70) ──────────────────────────────────────
// The host's timing ends at PostWebMessageAsString. If the renderer process is the
// starved component — plausible at 25 panes, where 60+ WebView2 processes were measured
// — every host-side number reads healthy while typing still stalls. These two probes
// cover that blind spot. Off unless the host sends setDiag, and each reports only when
// it crosses a threshold, so a healthy session produces no traffic at all.
var diagOn = false;
var lastPaintProbeMs = 0;

function diagReport(what, ms, len) {
try {
window.chrome.webview.postMessage(JSON.stringify({
type: 'diag', what: what, ms: ms, len: len
}));
} catch (e) {}
}

function diagWrite(data) {
if (!diagOn) { term.write(data); return; }

var t0 = performance.now();
term.write(data);
var t1 = performance.now();
if (t1 - t0 > 50) diagReport('write-blocked', t1 - t0, data.length);

// How long until the renderer actually produces a frame after this write. Sampled at
// most once a second: an rAF per output chunk across every pane would itself be load,
// and an instrument that changes the measurement is worth nothing here.
if (t1 - lastPaintProbeMs > 1000) {
lastPaintProbeMs = t1;
requestAnimationFrame(function () {
var lag = performance.now() - t1;
if (lag > 100) diagReport('paint-lag', lag, data.length);
});
}
}

// ── Messages from WPF ──────────────────────────────────────────────────────
window.chrome.webview.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'output') term.write(msg.data);
if (msg.type === 'output') diagWrite(msg.data);
else if (msg.type === 'setDiag') diagOn = !!msg.on;
else if (msg.type === 'clear') term.clear();
else if (msg.type === 'focus') { term.focus(); fitAddon.fit(); }
else if (msg.type === 'fit') { fitAddon.fit(); term.focus(); }
Expand Down
137 changes: 137 additions & 0 deletions src/CodeShellManager/Diagnostics/DiagnosticTrace.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace CodeShellManager.Diagnostics;

/// <summary>
/// Buffered, non-blocking writer for <c>[DEBUG-tt]</c> diagnostic lines (issue #70).
///
/// The original tracer called <see cref="Directory.CreateDirectory"/> plus a synchronous
/// <see cref="File.AppendAllText"/> on every single trace call — on the PTY read thread for
/// output, and on the UI thread for the flush. That is fine for a two-session repro and
/// actively harmful at the ~25-session workload this issue is about: tracing the stall would
/// have added a file open/append/close to the very thread whose latency is being measured,
/// and the run would have measured the instrument.
///
/// Callers enqueue a preformatted line and return immediately. A single background drain
/// writes batches to disk. Timestamps are taken at <see cref="Write"/> time, not at drain
/// time, so deferring the I/O does not distort the timings being recorded.
/// </summary>
public static class DiagnosticTrace
{
// Bounded so a runaway session can't turn a diagnostic into an OOM. Dropped lines are
// counted and reported in-band, because a silent gap in a latency log is worse than
// no log at all — it reads as a stall that never happened.
private const int MaxQueued = 20000;
private const int DrainIntervalMs = 250;

private static readonly ConcurrentQueue<string> Queue = new();
private static int _queued;
private static int _dropped;
private static int _started;
private static string? _path;

/// <summary>
/// Mirrors AppSettings.DebugTerminalTrace for code that has no access to settings —
/// notably <c>GitService</c>, which is deliberately WPF-free and cannot reach the VM.
/// </summary>
public static bool Enabled;

/// <summary>
/// Managed id of the WPF UI thread, stamped at startup. Lets a WPF-free service report
/// whether it is running on the UI thread without referencing a Dispatcher.
/// </summary>
public static int UiThreadId;

/// <summary>True when the caller is executing on the UI thread.</summary>
public static bool OnUiThread => Environment.CurrentManagedThreadId == UiThreadId;

/// <summary>Absolute path of the log being written. Resolved once, on first use.</summary>
public static string Path => _path ??= System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"CodeShellManager", "crash.log");

/// <summary>
/// Points the writer at a scratch file and empties any queued state. Tests only —
/// the drain loop is a process-lifetime singleton, so tests drive <see cref="DrainOnce"/>
/// directly instead of racing it.
/// </summary>
internal static void ResetForTests(string path)
{
_path = path;
_started = 1; // suppress the background loop; tests pump DrainOnce themselves
while (Queue.TryDequeue(out _)) { }
Volatile.Write(ref _queued, 0);
Volatile.Write(ref _dropped, 0);
}

/// <summary>
/// Queues one line. Safe from any thread, never touches the disk on the caller's thread.
/// The caller is expected to have already checked its trace flag.
/// </summary>
public static void Write(string tag, string? sessionId, string message)
{
if (Volatile.Read(ref _queued) >= MaxQueued)
{
Interlocked.Increment(ref _dropped);
return;
}

Interlocked.Increment(ref _queued);
Queue.Enqueue($"[{DateTime.Now:HH:mm:ss.fff}] [{tag}] {sessionId ?? "?"} {message}");
EnsureDrainStarted();
}

private static void EnsureDrainStarted()
{
if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) return;

try { Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); }
catch { }

// Long-running, so it gets its own thread rather than starving a pool thread that
// the PTY read path also wants.
_ = Task.Factory.StartNew(DrainLoop, TaskCreationOptions.LongRunning);
}

private static void DrainLoop()
{
while (true)
{
Thread.Sleep(DrainIntervalMs);
DrainOnce();
}
}

/// <summary>Writes everything queued so far as one append. Returns the line count.</summary>
internal static int DrainOnce()
{
var sb = new StringBuilder();
int lines = 0;

while (Queue.TryDequeue(out string? line))
{
Interlocked.Decrement(ref _queued);
sb.Append(line).Append('\n');
lines++;
}

int dropped = Interlocked.Exchange(ref _dropped, 0);
if (dropped > 0)
{
sb.Append($"[{DateTime.Now:HH:mm:ss.fff}] [DEBUG-tt] - " +
$"TRACE-OVERFLOW dropped={dropped} lines\n");
lines++;
}

if (sb.Length == 0) return 0;

try { File.AppendAllText(Path, sb.ToString()); }
catch { }
return lines;
}
}
80 changes: 80 additions & 0 deletions src/CodeShellManager/Diagnostics/UiThreadHeartbeat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Windows.Threading;
using CodeShellManager.Models;

namespace CodeShellManager.Diagnostics;

/// <summary>
/// Measures UI-thread responsiveness independently of any terminal session (issue #70).
///
/// A per-bridge <c>dispatcher-latency</c> figure cannot on its own distinguish "the whole UI
/// thread is saturated" from "this one bridge's batch was queued behind a big paint" — and
/// for background sessions it cannot distinguish either of those from ordinary
/// <see cref="DispatcherPriority.Background"/> yielding, which is working as designed.
///
/// This ticks at a fixed interval at <see cref="DispatcherPriority.Normal"/> and records how
/// late each tick actually ran. Overshoot here is UI-thread saturation, full stop, with no
/// session attribution needed. Correlating a typing stall against this timeline says whether
/// the pump was blocked at that moment or whether the delay lives somewhere else entirely.
/// </summary>
public sealed class UiThreadHeartbeat
{
private const int IntervalMs = 250;

// Only overshoot beyond this is logged. Timer resolution and ordinary paints produce a
// steady dribble of a few ms; logging those would bury the events that matter.
private const int ReportThresholdMs = 100;

private readonly AppSettings _settings;
private readonly DispatcherTimer _timer;
private long _expectedNextMs;
private long _worstMs;
private int _overCount;
private long _lastSummaryMs;

public UiThreadHeartbeat(AppSettings settings)
{
_settings = settings;
_timer = new DispatcherTimer(DispatcherPriority.Normal)
{
Interval = TimeSpan.FromMilliseconds(IntervalMs)
};
_timer.Tick += OnTick;
}

public void Start()
{
_expectedNextMs = Environment.TickCount64 + IntervalMs;
_lastSummaryMs = Environment.TickCount64;
_timer.Start();
}

public void Stop() => _timer.Stop();

private void OnTick(object? sender, EventArgs e)
{
long now = Environment.TickCount64;
long late = now - _expectedNextMs;
_expectedNextMs = now + IntervalMs;

if (_settings.DebugTerminalTrace != true) return;

if (late >= ReportThresholdMs)
{
_overCount++;
if (late > _worstMs) _worstMs = late;
DiagnosticTrace.Write("DEBUG-tt", "-", $"UI-STALL late={late}ms");
}

// A periodic summary so a log with no stall lines is still positive evidence that
// the pump was healthy, rather than ambiguous with "tracing wasn't on".
if (now - _lastSummaryMs >= 10000)
{
DiagnosticTrace.Write("DEBUG-tt", "-",
$"UI-HEARTBEAT window=10s stalls={_overCount} worst={_worstMs}ms");
_lastSummaryMs = now;
_overCount = 0;
_worstMs = 0;
}
}
}
Loading