Skip to content

Commit 7d7d63d

Browse files
authored
feat: make UpdateSearchText thread-safe and add CancellationToken-based suggestions cancellation (#60)
1 parent 9f875e0 commit 7d7d63d

9 files changed

Lines changed: 119 additions & 40 deletions

File tree

CmdPalWebSearchShortcut/WebSearchShortcut/Pages/SearchWebPage.cs

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Linq;
23
using System.Threading;
34
using System.Threading.Tasks;
@@ -16,7 +17,11 @@ internal sealed partial class SearchWebPage : DynamicListPage
1617
private readonly IListItem _openHomePageItem;
1718
private IListItem[] _items = [];
1819
private IListItem[] _suggestionItems = [];
19-
private int _updateEpoch;
20+
private int _lastUpdateSearchTextEpoch;
21+
private readonly Lock _swapSuggestionsCancellationSourceLock = new();
22+
private readonly Lock _renderLock = new();
23+
private readonly Lock _updateSuggestionLock = new();
24+
private CancellationTokenSource? _previousSuggestionsCancellationSource;
2025

2126
public SearchWebPage(WebSearchShortcutDataEntry shortcut)
2227
{
@@ -29,48 +34,114 @@ public SearchWebPage(WebSearchShortcutDataEntry shortcut)
2934
Title = StringFormatter.Format(Resources.OpenHomePage_TitleTemplate, new() { ["engine"] = Name })
3035
};
3136

32-
_updateEpoch = 0;
33-
3437
_items = [_openHomePageItem];
3538
}
3639

37-
public override IListItem[] GetItems() => _items;
40+
public override IListItem[] GetItems() => Volatile.Read(ref _items);
3841

3942
public override async void UpdateSearchText(string oldSearch, string newSearch)
4043
{
41-
var capturedEpoch = Interlocked.Increment(ref _updateEpoch);
44+
int currentEpoch = Interlocked.Increment(ref _lastUpdateSearchTextEpoch);
45+
46+
bool shouldOpenHomePage = string.IsNullOrEmpty(newSearch);
47+
bool shouldFetchSuggestions = !shouldOpenHomePage && !string.IsNullOrEmpty(_shortcut.SuggestionProvider);
48+
49+
CancellationTokenSource? currentCancellationSource = shouldFetchSuggestions ? new CancellationTokenSource() : null;
50+
CancellationTokenSource? previousCancellationSource;
4251

43-
if (string.IsNullOrEmpty(newSearch))
52+
lock (_swapSuggestionsCancellationSourceLock)
4453
{
45-
_suggestionItems = [];
54+
if (currentEpoch != Volatile.Read(ref _lastUpdateSearchTextEpoch))
55+
{
56+
currentCancellationSource?.Dispose();
57+
return;
58+
}
4659

47-
RenderItems([_openHomePageItem]);
60+
previousCancellationSource = Interlocked.Exchange(ref _previousSuggestionsCancellationSource, currentCancellationSource);
61+
}
62+
63+
try
64+
{
65+
previousCancellationSource?.Cancel();
66+
}
67+
catch (ObjectDisposedException)
68+
{
69+
}
70+
71+
if (shouldOpenHomePage)
72+
{
73+
UpdateSuggestionItems([], currentEpoch);
74+
75+
RenderItems([_openHomePageItem], currentEpoch);
4876

4977
return;
5078
}
5179

5280
var primaryItems = BuildPrimaryItems(newSearch);
81+
var snapshotSuggestions = Volatile.Read(ref _suggestionItems);
82+
83+
RenderItems([.. primaryItems, .. snapshotSuggestions], currentEpoch);
5384

54-
RenderItems([.. primaryItems, .. _suggestionItems]);
85+
if (!shouldFetchSuggestions)
86+
return;
87+
88+
IListItem[] suggestionItems;
89+
try
90+
{
91+
suggestionItems = await FetchSuggestionItemsAsync(newSearch, currentCancellationSource!.Token).ConfigureAwait(false);
92+
}
93+
catch (OperationCanceledException)
94+
{
95+
return;
96+
}
97+
catch (Exception ex)
98+
{
99+
ExtensionHost.LogMessage("Suggestions fetch failed: " + ex.ToString());
55100

56-
if (string.IsNullOrEmpty(_shortcut.SuggestionProvider))
57101
return;
102+
}
103+
finally
104+
{
105+
Interlocked.CompareExchange(ref _previousSuggestionsCancellationSource, null, currentCancellationSource);
106+
107+
currentCancellationSource!.Dispose();
108+
}
58109

59-
var suggestionItems = await GetSuggestionItemsAsync(newSearch);
110+
if (currentEpoch != Volatile.Read(ref _lastUpdateSearchTextEpoch)) return;
60111

61-
if (capturedEpoch != _updateEpoch)
112+
UpdateSuggestionItems(suggestionItems, currentEpoch);
113+
114+
RenderItems([.. primaryItems, .. suggestionItems], currentEpoch);
115+
}
116+
117+
private void RenderItems(IListItem[] items, int currentUpdateSearchTextEpoch)
118+
{
119+
if (currentUpdateSearchTextEpoch != Volatile.Read(ref _lastUpdateSearchTextEpoch))
62120
return;
63121

64-
_suggestionItems = suggestionItems;
122+
lock (_renderLock)
123+
{
124+
if (currentUpdateSearchTextEpoch != Volatile.Read(ref _lastUpdateSearchTextEpoch))
125+
return;
126+
127+
Volatile.Write(ref _items, items);
128+
}
65129

66-
RenderItems([.. primaryItems, .. _suggestionItems]);
130+
RaiseItemsChanged(items.Length);
67131
}
68132

69-
private void RenderItems(IListItem[] items)
133+
private void UpdateSuggestionItems(IListItem[] suggestionItems, int currentUpdateSearchTextEpoch)
70134
{
71-
_items = items;
135+
if (currentUpdateSearchTextEpoch != Volatile.Read(ref _lastUpdateSearchTextEpoch))
136+
return;
137+
138+
lock (_updateSuggestionLock)
139+
{
140+
if (currentUpdateSearchTextEpoch != Volatile.Read(ref _lastUpdateSearchTextEpoch))
141+
return;
72142

73-
RaiseItemsChanged(_items.Length);
143+
Volatile.Write(ref _suggestionItems, suggestionItems);
144+
}
74145
}
75146

76147
private ListItem[] BuildPrimaryItems(string searchText)
@@ -86,11 +157,11 @@ private ListItem[] BuildPrimaryItems(string searchText)
86157
];
87158
}
88159

89-
private async Task<ListItem[]> GetSuggestionItemsAsync(string searchText)
160+
private async Task<ListItem[]> FetchSuggestionItemsAsync(string searchText, CancellationToken cancellationToken)
90161
{
91162
var suggestions = await SuggestionsRegistry
92163
.Get(_shortcut.SuggestionProvider!)
93-
.GetSuggestionsAsync(searchText)
164+
.GetSuggestionsAsync(searchText, cancellationToken)
94165
.ConfigureAwait(false);
95166

96167
return

CmdPalWebSearchShortcut/WebSearchShortcut/Suggestion.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading;
45
using System.Threading.Tasks;
56
using WebSearchShortcut.SuggestionsProviders;
67

@@ -9,7 +10,7 @@ namespace WebSearchShortcut;
910
internal interface ISuggestionsProvider
1011
{
1112
string Name { get; }
12-
Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query);
13+
Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default);
1314
}
1415

1516
internal sealed record Suggestion(string Title, string? Description = null);

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/Bing.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Text.Json;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CommandPalette.Extensions.Toolkit;
89
using WebSearchShortcut.Properties;
@@ -15,18 +16,18 @@ internal sealed class Bing : ISuggestionsProvider
1516

1617
private HttpClient Http { get; } = new HttpClient();
1718

18-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
19+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
1920
{
2021
try
2122
{
2223
const string api = "https://api.bing.com/qsonhs.aspx?q=";
2324

2425
await using var resultStream = await Http
25-
.GetStreamAsync(api + Uri.EscapeDataString(query))
26+
.GetStreamAsync(api + Uri.EscapeDataString(query), cancellationToken)
2627
.ConfigureAwait(false);
2728

2829
using var json = await JsonDocument
29-
.ParseAsync(resultStream)
30+
.ParseAsync(resultStream, cancellationToken: cancellationToken)
3031
.ConfigureAwait(false);
3132

3233
var root = json.RootElement.GetProperty("AS");

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/CanIUse.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Text.Json;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CommandPalette.Extensions.Toolkit;
89
using WebSearchShortcut.Properties;
@@ -15,18 +16,18 @@ internal sealed class CanIUse : ISuggestionsProvider
1516

1617
private HttpClient Http { get; } = new HttpClient();
1718

18-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
19+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
1920
{
2021
try
2122
{
2223
const string api = "https://caniuse.com/process/query.php?search=";
2324

2425
await using var resultStream = await Http
25-
.GetStreamAsync(api + Uri.EscapeDataString(query))
26+
.GetStreamAsync(api + Uri.EscapeDataString(query), cancellationToken)
2627
.ConfigureAwait(false);
2728

2829
using var json = await JsonDocument
29-
.ParseAsync(resultStream)
30+
.ParseAsync(resultStream, cancellationToken: cancellationToken)
3031
.ConfigureAwait(false);
3132

3233
var featureIds = json

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/DuckDuckGo.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Text.Json;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CommandPalette.Extensions.Toolkit;
89
using WebSearchShortcut.Properties;
@@ -15,18 +16,18 @@ internal sealed class DuckDuckGo : ISuggestionsProvider
1516

1617
private HttpClient Http { get; } = new HttpClient();
1718

18-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
19+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
1920
{
2021
try
2122
{
2223
const string api = "https://duckduckgo.com/ac/?q=";
2324

2425
await using var resultStream = await Http
25-
.GetStreamAsync(api + Uri.EscapeDataString(query))
26+
.GetStreamAsync(api + Uri.EscapeDataString(query), cancellationToken)
2627
.ConfigureAwait(false);
2728

2829
using var json = await JsonDocument
29-
.ParseAsync(resultStream)
30+
.ParseAsync(resultStream, cancellationToken: cancellationToken)
3031
.ConfigureAwait(false);
3132

3233
var results = json.RootElement;

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/Google.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Text.Json;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CommandPalette.Extensions.Toolkit;
89
using WebSearchShortcut.Properties;
@@ -15,18 +16,18 @@ internal sealed class Google : ISuggestionsProvider
1516

1617
private HttpClient Http { get; } = new HttpClient();
1718

18-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
19+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
1920
{
2021
try
2122
{
2223
const string api = "https://www.google.com/complete/search?output=chrome&q=";
2324

2425
await using var resultStream = await Http
25-
.GetStreamAsync(api + Uri.EscapeDataString(query))
26+
.GetStreamAsync(api + Uri.EscapeDataString(query), cancellationToken)
2627
.ConfigureAwait(false);
2728

2829
using var json = await JsonDocument
29-
.ParseAsync(resultStream)
30+
.ParseAsync(resultStream, cancellationToken: cancellationToken)
3031
.ConfigureAwait(false);
3132

3233
var results = json.RootElement.EnumerateArray().ElementAt(1);

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/Npm.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Text.Json;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CommandPalette.Extensions.Toolkit;
89
using WebSearchShortcut.Properties;
@@ -15,18 +16,18 @@ internal sealed class Npm : ISuggestionsProvider
1516

1617
private HttpClient Http { get; } = new HttpClient();
1718

18-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
19+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
1920
{
2021
try
2122
{
2223
const string api = "https://www.npmjs.com/search/suggestions?q=";
2324

2425
await using var resultStream = await Http
25-
.GetStreamAsync(api + Uri.EscapeDataString(query))
26+
.GetStreamAsync(api + Uri.EscapeDataString(query), cancellationToken)
2627
.ConfigureAwait(false);
2728

2829
using var json = await JsonDocument
29-
.ParseAsync(resultStream)
30+
.ParseAsync(resultStream, cancellationToken: cancellationToken)
3031
.ConfigureAwait(false);
3132

3233
var results = json.RootElement.EnumerateArray();

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/Wikipedia.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Net.Http;
55
using System.Text.Json;
6+
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CommandPalette.Extensions.Toolkit;
89
using WebSearchShortcut.Properties;
@@ -15,18 +16,18 @@ internal sealed class Wikipedia : ISuggestionsProvider
1516

1617
private HttpClient Http { get; } = new HttpClient();
1718

18-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
19+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
1920
{
2021
try
2122
{
2223
const string api = "https://api.wikimedia.org/core/v1/wikipedia/en/search/title?q=";
2324

2425
await using var resultStream = await Http
25-
.GetStreamAsync(api + Uri.EscapeDataString(query))
26+
.GetStreamAsync(api + Uri.EscapeDataString(query), cancellationToken)
2627
.ConfigureAwait(false);
2728

2829
using var json = await JsonDocument
29-
.ParseAsync(resultStream)
30+
.ParseAsync(resultStream, cancellationToken: cancellationToken)
3031
.ConfigureAwait(false);
3132

3233
var results = json.RootElement.GetProperty("pages");

CmdPalWebSearchShortcut/WebSearchShortcut/SuggestionsProviders/YouTube.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Net.Http;
55
using System.Text.Json;
66
using System.Text.RegularExpressions;
7+
using System.Threading;
78
using System.Threading.Tasks;
89
using Microsoft.CommandPalette.Extensions.Toolkit;
910
using WebSearchShortcut.Properties;
@@ -16,14 +17,14 @@ internal sealed class YouTube : ISuggestionsProvider
1617

1718
private HttpClient Http { get; } = new HttpClient();
1819

19-
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query)
20+
public async Task<IReadOnlyList<Suggestion>> GetSuggestionsAsync(string query, CancellationToken cancellationToken = default)
2021
{
2122
try
2223
{
2324
const string api = "https://suggestqueries-clients6.youtube.com/complete/search?ds=yt&client=youtube&gs_ri=youtube&q=";
2425

2526
var result = await Http
26-
.GetStringAsync(api + Uri.EscapeDataString(query))
27+
.GetStringAsync(api + Uri.EscapeDataString(query), cancellationToken)
2728
.ConfigureAwait(false);
2829

2930
var match = Regex.Match(result, @"window\.google\.ac\.h\((.*)\)$");

0 commit comments

Comments
 (0)