From e7671974add99e298fbcf343e03cb94d920a0bcb Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Tue, 6 Oct 2026 23:53:16 +0530 Subject: [PATCH 1/4] chore(packaging): point the Homebrew formula at v7.0.19-beta. --- tools/packaging/homebrew/titanium.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/packaging/homebrew/titanium.rb b/tools/packaging/homebrew/titanium.rb index d6e856aa9..b826b1483 100644 --- a/tools/packaging/homebrew/titanium.rb +++ b/tools/packaging/homebrew/titanium.rb @@ -10,17 +10,17 @@ class Titanium < Formula desc "Titanium Web Proxy CLI (MITM / reverse proxy)" homepage "https://github.com/justcoding121/titanium-web-proxy" - version "7.0.18" + version "7.0.19-beta" license "MIT" on_macos do on_arm do url "https://github.com/justcoding121/titanium-web-proxy/releases/download/v#{version}/Titanium.Cli-osx-arm64.zip" - sha256 "8375729ba30c78ffa78e205dc1eb32b5bbc8b6ee7b8c61c5c3f0614dd42f592b" + sha256 "501ac9b954caadd2dbe620b6256b0634956a41dcef7e9515f1468b7447a58928" end on_intel do url "https://github.com/justcoding121/titanium-web-proxy/releases/download/v#{version}/Titanium.Cli-osx-x64.zip" - sha256 "56f23954bbf9ba424d3082e3875b3b5c7e5e1658b20368a2ddf9dc27067e3f87" + sha256 "223f8febc441539fc40194a27f210d550d21a0c7d6ec04a800bace0b3560a0e4" end end From efd4cd7bbda931bcd6c24fb39c15e26dbc214dff Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Wed, 7 Oct 2026 01:12:48 +0530 Subject: [PATCH 2/4] chore(packaging): point the Homebrew formula at v7.0.19. --- tools/packaging/homebrew/titanium.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/packaging/homebrew/titanium.rb b/tools/packaging/homebrew/titanium.rb index b826b1483..4eb913a94 100644 --- a/tools/packaging/homebrew/titanium.rb +++ b/tools/packaging/homebrew/titanium.rb @@ -10,17 +10,17 @@ class Titanium < Formula desc "Titanium Web Proxy CLI (MITM / reverse proxy)" homepage "https://github.com/justcoding121/titanium-web-proxy" - version "7.0.19-beta" + version "7.0.19" license "MIT" on_macos do on_arm do url "https://github.com/justcoding121/titanium-web-proxy/releases/download/v#{version}/Titanium.Cli-osx-arm64.zip" - sha256 "501ac9b954caadd2dbe620b6256b0634956a41dcef7e9515f1468b7447a58928" + sha256 "807a2e7ddbe3572404112b7b7c10187c92d4c87191b35528d2dcf82259d8db16" end on_intel do url "https://github.com/justcoding121/titanium-web-proxy/releases/download/v#{version}/Titanium.Cli-osx-x64.zip" - sha256 "223f8febc441539fc40194a27f210d550d21a0c7d6ec04a800bace0b3560a0e4" + sha256 "87fe8c227cd9fc5ef376af4dfd0552aff919b0b4fb19e743c404ec0143fa6d37" end end From 4a524117017aa869a41ae5154d43f18035304368 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Wed, 7 Oct 2026 11:31:33 +0530 Subject: [PATCH 3/4] feat(inspector): wipe the current session run on clear and remove empty cache folders Clear sessions deletes the current run, including rows the list limit already hid. Session retention can wipe every saved run after confirmation, and empty run folders are removed instead of accumulating. --- .../Services/SessionBodyDiskCache.cs | 257 +++++++++++++++-- .../Services/SessionStore.cs | 49 +++- .../MainWindowViewModel.Sessions.cs | 8 +- .../ViewModels/MainWindowViewModel.cs | 2 +- .../Views/SessionRetentionWindow.axaml | 26 +- .../Views/SessionRetentionWindow.axaml.cs | 95 +++++- .../AutomationIdCoverageHeadlessTests.cs | 2 + .../CaptureSettingsParityTests.cs | 11 + .../InspectorBodyHonestyTests.cs | 2 + .../SessionStoreRetentionTests.cs | 272 ++++++++++++++++++ website/docs/inspector.md | 17 +- 11 files changed, 702 insertions(+), 39 deletions(-) diff --git a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs index fc7179bc4..7f3a6cd5f 100644 --- a/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs +++ b/src/Titanium.Inspector/Services/SessionBodyDiskCache.cs @@ -3,11 +3,15 @@ namespace Titanium.Inspector.Services; +/// Tracked HAR files under the session cache, for the retention window. +public readonly record struct SessionCacheStats(int RunCount, long TotalBytes); + /// /// On-disk session cache under a size budget. Each Inspector process run writes into a /// timestamped subfolder under the cache root ({root}/{yyyyMMdd-HHmmss-fff}/{id}.har) -/// so restarted session ids never overwrite another run. Disk budget counts all runs and -/// deletes oldest HAR files first (across folders). +/// so restarted session ids never overwrite another run. The run folder is created on the +/// first write. Disk budget counts all runs and deletes oldest HAR files first (across folders). +/// Empty run folders other than the current run are removed. /// public sealed class SessionBodyDiskCache : IDisposable { @@ -28,6 +32,8 @@ public sealed class SessionBodyDiskCache : IDisposable StringComparer.OrdinalIgnoreCase); private long _trackedBytes; private bool _disposed; + private static readonly object RunPathReserveGate = new(); + private static readonly HashSet ReservedRunPaths = new(StringComparer.OrdinalIgnoreCase); public SessionBodyDiskCache(string rootDirectory, long maxBytes, TimeSpan maxAge) : this(rootDirectory, maxBytes, maxAge, runStartedUtc: DateTimeOffset.UtcNow) @@ -44,6 +50,7 @@ internal SessionBodyDiskCache( Directory.CreateDirectory(_rootDirectory); _runDirectory = CreateRunDirectory(_rootDirectory, runStartedUtc); RebuildIndexAndEnforceBudget(); + SweepEmptyRunFolders(); } /// Updates disk budget. Index updates return immediately; file deletes run in the background. @@ -98,6 +105,63 @@ public static string GetDefaultDirectory() => /// Alias for (UI / options). public string DirectoryPath => _rootDirectory; + /// + /// Run count and tracked bytes. Run count includes indexed parents and on-disk run + /// folders that still contain a HAR. Bytes come from the size index. + /// + public SessionCacheStats GetCacheStats() + { + HashSet runs; + long bytes; + lock (_gate) + { + bytes = _trackedBytes; + runs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in _index.Keys) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + runs.Add(Path.GetFullPath(dir)); + } + } + } + + if (!Directory.Exists(_rootDirectory)) + { + return new SessionCacheStats(runs.Count, bytes); + } + + try + { + var rootFull = Path.GetFullPath(_rootDirectory); + if (Directory.EnumerateFiles(_rootDirectory, SessionFileSearchPattern).Any()) + { + runs.Add(rootFull); + } + + foreach (var sub in Directory.EnumerateDirectories(_rootDirectory)) + { + var full = Path.GetFullPath(sub); + if (runs.Contains(full)) + { + continue; + } + + if (Directory.EnumerateFiles(sub, SessionFileSearchPattern).Any()) + { + runs.Add(full); + } + } + } + catch + { + // Best-effort stats while a delete is in progress. + } + + return new SessionCacheStats(runs.Count, bytes); + } + public string PathFor(long sessionId) => Path.Combine(_runDirectory, sessionId.ToString("D", CultureInfo.InvariantCulture) + ".har"); @@ -250,25 +314,29 @@ public void DeleteMany(IEnumerable sessionIds) } /// - /// Drops the current run folder from the index and moves it aside. New writes use a fresh folder. - /// The moved folder is deleted on a background thread. Other run folders are left in place. + /// Drops the current run folder from the index and moves it aside. New writes use a fresh + /// folder path, created on the next write. The moved folder is deleted on a background thread. + /// Other run folders are left in place. Returns tracked bytes queued for deletion. /// - public void AbandonCurrentRun() + public long AbandonCurrentRun() { string old; + long freed; lock (_writeGate) { lock (_gate) { old = _runDirectory; Interlocked.Increment(ref _runGeneration); + var before = _trackedBytes; RemoveIndexEntriesUnder(old); + freed = Math.Max(0, before - _trackedBytes); _runDirectory = CreateRunDirectory(_rootDirectory, DateTimeOffset.UtcNow); } if (!Directory.Exists(old)) { - return; + return freed; } var trash = Path.Combine(Path.GetTempPath(), "ti-discard-" + Guid.NewGuid().ToString("N")); @@ -282,6 +350,32 @@ public void AbandonCurrentRun() QueueCleanup([old]); } } + + return freed; + } + + /// + /// Rotates the current run so in-flight spills are rejected, then deletes every run folder + /// on the cleanup queue. The replacement folder is not created until the next write. + /// Returns tracked bytes queued for deletion. Does not enumerate the cache on the caller thread. + /// + public long ScheduleClearAllRuns() + { + long freed; + lock (_writeGate) + { + lock (_gate) + { + freed = _trackedBytes; + Interlocked.Increment(ref _runGeneration); + _index.Clear(); + _trackedBytes = 0; + _runDirectory = CreateRunDirectory(_rootDirectory, DateTimeOffset.UtcNow); + } + } + + QueueFullWipe(); + return freed; } /// Removes session files from the size index immediately and deletes them in the background. @@ -406,7 +500,6 @@ public void ClearAllRuns() } } - Directory.CreateDirectory(_runDirectory); lock (_gate) { _index.Clear(); @@ -636,16 +729,22 @@ private void TryDeleteEmptyRunFolder(string? folder) return; } - try + for (var attempt = 0; attempt < 8; attempt++) { - if (Directory.Exists(full) && IsEmptyDirectory(full)) + try { + if (!Directory.Exists(full) || !IsEmptyDirectory(full)) + { + return; + } + Directory.Delete(full, recursive: false); + return; + } + catch + { + Thread.Sleep(25 * (attempt + 1)); } - } - catch - { - // Best-effort. } } @@ -736,6 +835,103 @@ private void DrainCleanupBatch() { TryDeletePathWithRetry(path); } + + SweepEmptyRunFolders(); + } + + private void QueueFullWipe() + { + lock (_cleanupGate) + { + _cleanupTask = _cleanupTask.ContinueWith( + _ => RunFullWipe(), + CancellationToken.None, + TaskContinuationOptions.RunContinuationsAsynchronously, + TaskScheduler.Default); + } + } + + /// + /// Deletes every run folder except whichever path is current at the moment of each delete, + /// so a second clear cannot lose the newest run to an older wipe. + /// + private void RunFullWipe() + { + LastCleanupThreadId = Environment.CurrentManagedThreadId; + if (!Directory.Exists(_rootDirectory)) + { + return; + } + + List dirs; + try + { + dirs = Directory.EnumerateDirectories(_rootDirectory).ToList(); + } + catch + { + return; + } + + foreach (var sub in dirs) + { + if (IsCurrentRunDirectory(sub)) + { + continue; + } + + TryDeletePathWithRetry(sub); + } + + try + { + foreach (var file in Directory.EnumerateFiles(_rootDirectory).ToList()) + { + TryDeletePathWithRetry(file); + } + } + catch + { + // Best-effort. + } + } + + private bool IsCurrentRunDirectory(string path) + { + lock (_writeGate) + { + return string.Equals( + Path.GetFullPath(path), + Path.GetFullPath(_runDirectory), + StringComparison.OrdinalIgnoreCase); + } + } + + /// Deletes empty subfolders except the current run and the cache root. + private void SweepEmptyRunFolders() + { + lock (_writeGate) + { + if (!Directory.Exists(_rootDirectory)) + { + return; + } + + List subs; + try + { + subs = Directory.EnumerateDirectories(_rootDirectory).ToList(); + } + catch + { + return; + } + + foreach (var sub in subs) + { + TryDeleteEmptyRunFolder(sub); + } + } } private static void TryDeletePathWithRetry(string path) @@ -783,17 +979,40 @@ private void RemoveIndexEntriesUnder(string directory) } } + /// + /// Picks a unique run-folder path. The directory is created on the first write so a run + /// that captures nothing does not leave an empty folder. The name is reserved in-process + /// so two caches started in the same millisecond do not share a path before either exists. + /// internal static string CreateRunDirectory(string root, DateTimeOffset startedUtc) { var stamp = startedUtc.UtcDateTime.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture); - var path = Path.Combine(root, stamp); - // Extremely unlikely collision within the same millisecond. - if (Directory.Exists(path)) + lock (RunPathReserveGate) { - path = Path.Combine(root, stamp + "-" + Guid.NewGuid().ToString("N")[..6]); + var path = Path.Combine(root, stamp); + if (TryReserveRunPath(path)) + { + return path; + } + + for (var i = 0; i < 5; i++) + { + path = Path.Combine(root, stamp + "-" + Guid.NewGuid().ToString("N")[..6]); + if (TryReserveRunPath(path)) + { + return path; + } + } + + path = Path.Combine(root, stamp + "-" + Guid.NewGuid().ToString("N")); + TryReserveRunPath(path); + return path; } + } - Directory.CreateDirectory(path); - return path; + private static bool TryReserveRunPath(string path) + { + var full = Path.GetFullPath(path); + return !Directory.Exists(full) && ReservedRunPaths.Add(full); } } diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs index ffa5f7f6c..18a194068 100644 --- a/src/Titanium.Inspector/Services/SessionStore.cs +++ b/src/Titanium.Inspector/Services/SessionStore.cs @@ -7,6 +7,8 @@ namespace Titanium.Inspector.Services; /// Captured sessions: in-memory list (headers/metadata; bodies unloaded), HAR archive on disk /// (per-run subfolders under the cache root), and two independent limits — MaxSessionsInMemory /// (drop rows from the list) and DiskCacheMaxBytes (delete oldest HAR files across runs). +/// Empty run folders other than the current run are removed. The current run folder is created +/// on the first spill. /// public sealed class SessionStore : IDisposable { @@ -265,11 +267,13 @@ public void Remove(IEnumerable ids) } /// - /// Drops every in-memory session and rotates the current-run HAR folder. - /// Does not raise — the caller clears the grid itself. + /// Drops every in-memory session and deletes the current-run HAR folder, including rows + /// already evicted from the list. Earlier runs stay on disk. Does not raise + /// — the caller clears the grid itself. /// Disk deletion continues in the background (). + /// Returns tracked bytes queued for deletion. /// - public void Clear() + public long Clear() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_gate) @@ -281,9 +285,46 @@ public void Clear() Interlocked.Increment(ref _spillEpoch); } - _disk?.AbandonCurrentRun(); + return _disk?.AbandonCurrentRun() ?? 0; } + /// + /// Deletes every saved run, including the current one. In-flight spills are rejected via + /// the run generation. Rows stay in the list; spilled bodies are marked missing. + /// Deletion runs in the background (). + /// Returns tracked bytes queued for deletion. + /// + public long ClearAllSavedRuns() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_disk is null) + { + return 0; + } + + long freed; + List spilled; + lock (_gate) + { + spilled = new List(); + foreach (var snap in _byId.Values) + { + if (snap.BodiesOnDisk) + { + spilled.Add(snap.Id); + } + } + + freed = _disk.ScheduleClearAllRuns(); + } + + MarkBodiesMissing(spilled); + return freed; + } + + /// Run count and tracked bytes for the retention window. Zero when spill is off. + public SessionCacheStats GetCacheStats() => _disk?.GetCacheStats() ?? default; + /// /// Inserts many sessions under one lock and enforces the memory cap once. /// Spill writes are queued; HAR files are not written on the caller thread. diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs index 1977ddda0..d60a41122 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs @@ -41,10 +41,11 @@ private Task ClearSessionsAsync() _userRemovalDepth++; _suppressOpenSessionDetails = true; + long freed; try { // Store.Clear does not raise SessionsRemoved. One grid Clear is a single Reset. - _store.Clear(); + freed = _store.Clear(); Sessions.Clear(); } finally @@ -57,7 +58,10 @@ private Task ClearSessionsAsync() _interception.ResetSessionIdSequence(); RefreshSessionCountText(); NotifyFilterSelectionProperties(); - SetOutcomeStatus("Sessions cleared", StatusSeverity.Success, toastImportant: true); + SetOutcomeStatus( + SessionRetentionWindow.FormatSessionsClearedStatus(freed), + StatusSeverity.Success, + toastImportant: true); return Task.CompletedTask; } private Task RemoveSelectedSessionsAsync() diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 1ccd10ea1..a63ad2b40 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1030,7 +1030,7 @@ private async Task OpenSessionRetentionAsync() return; } - var saved = await AwaitCancellableAsync(SessionRetentionWindow.ShowAsync(owner, _settings)); + var saved = await AwaitCancellableAsync(SessionRetentionWindow.ShowAsync(owner, _settings, _store)); if (!saved) { StatusText = "Session retention cancelled"; diff --git a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml index f148332a9..94a714750 100644 --- a/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml +++ b/src/Titanium.Inspector/Views/SessionRetentionWindow.axaml @@ -3,7 +3,7 @@ x:Class="Titanium.Inspector.Views.SessionRetentionWindow" AutomationProperties.AutomationId="SessionRetentionWindow" Title="Session retention" - Width="520" Height="400" + Width="520" Height="460" WindowStartupLocation="CenterOwner" CanResize="False"> @@ -18,7 +18,7 @@ + Text="How many requests stay in the session list. When over this, the oldest rows leave the list but remain on disk until Clear sessions or the disk space limit removes them." /> @@ -31,12 +31,20 @@ AcceptsReturn="True" Opacity="0.85" AutomationProperties.AutomationId="RetentionCacheFolderPath" /> -