fix(lsp): find Go usages in build-constrained files and list their callers (#1911)

gopls builds a module for the host's GOOS/GOARCH only, so usages in files such as foo_windows.go were missing from Find Usages on macOS, and renaming left them behind, breaking the build for that platform. Those files of the searched modules are now resolved the way other modules are: each mention of the name in its own view (gopls builds one for the file's platform), added after gopls' answer and included in Rename.

Incoming Calls now also lists the callers found this way, in other modules and in build-constrained files: the declaration containing each call site.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-07 11:45:06 +08:00
1 джерело 5084388a68
коміт 159c2d6fd2
4 files changed
+305 -45

No files matched your search

+134 -17
Переглянути файл
@@ -1,20 +1,23 @@
//! Go modules a gopls search does not reach.
//! Go files a gopls search does not reach.
//!
//! gopls answers a search from the view of the file it is asked about: the
//! modules of a `go.work`, or that file's own module. Opening more files does
//! not widen it (each gets a view of its own), so the generic scope widening
//! is skipped for Go. Usages in other modules of the project, which use the
//! declaration's module through a local `replace` (a multi-module repository
//! without `go.work`), are found as GoLand finds them: their mentions of the
//! name are resolved one by one in their own module's view.
//! modules of a `go.work`, or that file's own module, built for the host's
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
//! gets a view of its own), so the generic scope widening is skipped for Go.
//! Usages it leaves out are found as GoLand finds them, by resolving each
//! mention of the name in its own file's view: files of other modules that use
//! the declaration's module through a local `replace` (a multi-module
//! repository without `go.work`), and files whose build constraints exclude
//! them from the default build (`_windows.go`, `//go:build`).
use ide_core::{Pos, Range};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The files of `candidates` in modules the search at `origin` does not cover and
/// that can refer to `declaration`: its own module, or one replacing it with its directory.
pub(crate) fn other_module_files(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
/// The files of `candidates` the search at `origin` does not cover and that can refer to
/// `declaration`: those of the covered modules excluded from their default build, and those
/// of the declaration's own module or one replacing it with its directory when not covered.
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut modules = ModuleRoots::default();
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
return Vec::new();
@@ -27,8 +30,9 @@ pub(crate) fn other_module_files(origin: &Path, declaration: &Path, candidates:
.into_iter()
.filter(|file| {
let Some(module) = modules.of(file) else { return false };
// The covered modules share the search's view: only their excluded files are missed.
if covered.contains(&module) {
return false;
return !in_default_build(file);
}
*uses.entry(module.clone()).or_insert_with(|| {
module == declaring
@@ -42,6 +46,108 @@ pub(crate) fn other_module_files(origin: &Path, declaration: &Path, candidates:
.collect()
}
/// Whether gopls' default view builds `file`: no build constraint other than the host's
/// own GOOS/GOARCH file name suffix.
fn in_default_build(file: &Path) -> bool {
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let stem = name.strip_suffix(".go").unwrap_or(&name);
let stem = stem.strip_suffix("_test").unwrap_or(stem);
let parts: Vec<&str> = stem.split('_').collect();
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
if parts.len() > 1 {
let last = parts[parts.len() - 1];
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
return false;
}
if parts.len() > 2 && GOARCH.contains(&last) {
let os = parts[parts.len() - 2];
if GOOS.contains(&os) && !host(&os) {
return false;
}
}
}
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
}
/// A `//go:build` or `// +build` line before the package clause.
fn constrained_header(text: &str) -> bool {
for line in text.lines() {
let line = line.trim();
if line.starts_with("package ") {
return false;
}
if line.starts_with("//go:build") || line.starts_with("// +build") {
return true;
}
}
false
}
fn host_goos() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
os => os,
}
}
fn host_goarch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
"powerpc64" => "ppc64",
arch => arch,
}
}
const GOOS: &[&str] = &[
"aix",
"android",
"darwin",
"dragonfly",
"freebsd",
"hurd",
"illumos",
"ios",
"js",
"linux",
"nacl",
"netbsd",
"openbsd",
"plan9",
"solaris",
"wasip1",
"windows",
"zos",
];
const GOARCH: &[&str] = &[
"386",
"amd64",
"amd64p32",
"arm",
"armbe",
"arm64",
"arm64be",
"loong64",
"mips",
"mipsle",
"mips64",
"mips64le",
"mips64p32",
"mips64p32le",
"ppc",
"ppc64",
"ppc64le",
"riscv",
"riscv64",
"s390",
"s390x",
"sparc",
"sparc64",
"wasm",
];
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
let ident = |c: char| c.is_alphanumeric() || c == '_';
@@ -185,14 +291,19 @@ mod tests {
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
let candidates = vec![
write(root, "same.go", "package app\n"),
write(root, "same_plan9.go", "package app\n"),
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
write(root, "same_plan9_test.go", "package app\n"),
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
write(root, "plan9.go", "package app\n"),
write(root, "example/a/main.go", "package main\n"),
write(root, "example/b/main.go", "package main\n"),
write(root, "example/c/main.go", "package main\n"),
write(root, "example/d/main.go", "package main\n"),
];
assert_eq!(
names(root, other_module_files(&origin, &origin, candidates)),
["example/a/main.go", "example/b/main.go"]
names(root, files_beyond_search(&origin, &origin, candidates)),
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
);
}
@@ -205,9 +316,15 @@ mod tests {
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
let declaration = write(root, "lib/lib.go", "package lib\n");
let candidates =
vec![write(root, "app/main.go", "package main\n"), write(root, "tool/main.go", "package main\n")];
assert_eq!(names(root, other_module_files(&declaration, &declaration, candidates)), ["tool/main.go"]);
let candidates = vec![
write(root, "app/main.go", "package main\n"),
write(root, "app/main_plan9.go", "package main\n"),
write(root, "tool/main.go", "package main\n"),
];
assert_eq!(
names(root, files_beyond_search(&declaration, &declaration, candidates)),
["app/main_plan9.go", "tool/main.go"]
);
}
#[test]
@@ -219,7 +336,7 @@ mod tests {
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
let origin = write(root, "cmd/main.go", "package main\n");
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
assert_eq!(names(root, other_module_files(&origin, &declaration, candidates)), ["other.go"]);
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
}
#[test]
+11 -2
Переглянути файл
@@ -1696,7 +1696,7 @@ impl LspManager {
{
partial(v.clone());
}
v.locations.extend(self.go_other_module_references(&snap, t, pos, &cancel).await);
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
@@ -2136,7 +2136,7 @@ impl LspManager {
// The second edit covers the first: its files stay analysed.
let mut edit = w.second.unwrap_or(w.first);
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
for l in self.go_other_module_references(snap, t, pos, cancel).await {
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
more.entry(l.path.to_path_buf())
.or_default()
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
@@ -2592,6 +2592,15 @@ impl LspManager {
}
HierarchyDirection::Outgoing => (query().await?, None),
};
let mut out = out;
if direction == HierarchyDirection::Incoming {
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
Some(item) => item.from_ranges.extend(caller.from_ranges),
None => out.push(caller),
}
}
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
+106 -18
Переглянути файл
@@ -11,7 +11,7 @@
use crate::server::{Running, Server};
use crate::text::DocText;
use crate::types::ReferencesIncomplete;
use crate::types::{HierarchyItem, ReferencesIncomplete};
use crate::{DocSnap, LspManager, Target};
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
@@ -53,6 +53,14 @@ pub struct CandidateFiles {
/// blocking thread and should stop early when cancelled.
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
/// What a Go search adds beyond gopls' own answer.
#[derive(Default)]
pub(crate) struct GoMentions {
pub locations: Vec<Location>,
/// The declarations containing them, as incoming calls (when asked for).
pub callers: Vec<HierarchyItem>,
}
/// A reference-based answer and, when files were opened for it, the second one.
pub(crate) struct Widened<R> {
pub first: R,
@@ -166,20 +174,21 @@ impl LspManager {
}
}
/// Go: usages of the symbol at `pos` in the project's other modules that use its module
/// through a local `replace`, which the search at `pos` does not reach (see `go_scope`).
/// Each mention of the name there is resolved in its own module's view.
pub(crate) async fn go_other_module_references(
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
/// also the declarations containing them, as incoming calls.
pub(crate) async fn go_mentions_beyond_search(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: &CancellationToken,
) -> Vec<Location> {
callers: bool,
) -> GoMentions {
if snap.language.as_str() != "go" || !t.caps().open_close {
return Vec::new();
return GoMentions::default();
}
let Some(word) = search_word(snap, pos) else { return Vec::new() };
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
let declarations: Vec<Location> = self
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
.await
@@ -187,11 +196,11 @@ impl LspManager {
.into_iter()
.filter(|l| l.uri.is_none())
.collect();
let Some(declaration) = declarations.first() else { return Vec::new() };
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return Vec::new() };
let Some(declaration) = declarations.first() else { return GoMentions::default() };
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
let files = tokio::task::spawn_blocking(move || {
crate::go_scope::other_module_files(&origin, &declared, candidates.files)
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
.into_iter()
.filter_map(|f| {
let text = std::fs::read_to_string(&f).ok()?;
@@ -203,14 +212,17 @@ impl LspManager {
.await
.unwrap_or_default();
if files.is_empty() || cancel.is_cancelled() {
return Vec::new();
return GoMentions::default();
}
// Open, each file gives gopls a view of its module to resolve the mentions in.
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
let texts: Vec<(PathBuf, Arc<DocText>)> =
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
.into_iter()
.flat_map(|(path, text, ranges)| {
let text = Arc::new(text);
.zip(&texts)
.flat_map(|((path, _, ranges), (_, text))| {
let text = text.clone();
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
})
.collect();
@@ -249,10 +261,86 @@ impl LspManager {
.buffer_unordered(8)
.collect()
.await;
if cancel.is_cancelled() {
return Vec::new();
let locations: Vec<Location> = found.into_iter().flatten().collect();
let mut out = GoMentions { callers: Vec::new(), locations };
if callers && !cancel.is_cancelled() {
for (path, text) in &texts {
let here: Vec<ide_core::Range> =
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
if !here.is_empty() {
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
}
}
}
found.into_iter().flatten().collect()
if cancel.is_cancelled() {
return GoMentions::default();
}
out
}
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
async fn go_callers_in(
&self,
t: &Target,
path: &Path,
text: &DocText,
calls: &[ide_core::Range],
cancel: &CancellationToken,
) -> Vec<HierarchyItem> {
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
let range =
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
// (name, kind, detail, range, selection range) of every declaration.
let mut symbols = Vec::new();
match r {
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
let mut stack = nested;
while let Some(s) = stack.pop() {
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
stack.extend(s.children.unwrap_or_default());
}
}
#[allow(deprecated)]
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
for s in flat {
let r = range(s.location.range);
symbols.push((s.name, s.kind, None, r, r));
}
}
_ => return Vec::new(),
}
let contains =
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
let mut items: Vec<HierarchyItem> = Vec::new();
for call in calls {
// The innermost function or method; a package-level declaration otherwise.
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
symbols
.iter()
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
};
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
continue;
};
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
Some(item) => item.from_ranges.push(*call),
None => items.push(HierarchyItem {
name: name.clone(),
kind: crate::convert::symbol_kind(*kind),
detail: detail.clone(),
path: path.to_path_buf().into(),
uri: None,
range: *r,
selection_range: *selection,
from_ranges: vec![*call],
}),
}
}
items
}
/// Opens `files` on `t` (read from disk) unless the user has them open;
+54 -8
Переглянути файл
@@ -2932,7 +2932,7 @@ async fn references_open_unopened_files_that_mention_the_name() {
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf) {
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
let s = h.launcher.state("gopls");
let write = |rel: &str, text: &str| {
@@ -2948,14 +2948,17 @@ fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf)
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
write("other/o.go", "package other\nvar b = app.New()\n");
// Built for plan9 only: outside gopls' default view of the root module.
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
let decl = h.root.join("a.go");
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go")];
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: candidates.clone(),
limit_hit: false,
}));
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
let (decl_uri, same_uri, tools_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools));
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
let line = params["position"]["line"].as_u64().unwrap_or(0);
@@ -2974,15 +2977,30 @@ fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf)
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
}
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
])),
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
])),
"textDocument/prepareCallHierarchy" => Some(
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
),
"callHierarchy/incomingCalls" => Some(json!([{
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
"fromRanges": [at(1, 8)],
}])),
_ => None,
}
}));
(h, s, decl, same, tools)
(h, s, decl, same, tools, plan9)
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
let (h, s, decl, same, tools) = go_modules_harness();
let (h, s, decl, same, tools, plan9) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
@@ -2998,7 +3016,7 @@ async fn go_searches_resolve_other_modules_that_replace_the_declarations_module(
let mut found: Vec<(PathBuf, u32)> =
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
found.sort();
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1)];
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
want.sort();
assert_eq!(found, want);
// Those in gopls' reach arrive first.
@@ -3006,6 +3024,7 @@ async fn go_searches_resolve_other_modules_that_replace_the_declarations_module(
// Only the module replacing the declaration's module was opened, never the root module's file.
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(opened.contains(&uri_of(&tools)));
assert!(opened.contains(&uri_of(&plan9)));
assert!(!opened.contains(&uri_of(&same)));
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
assert_eq!(s.requested("textDocument/references").len(), 1);
@@ -3023,15 +3042,42 @@ async fn go_searches_resolve_other_modules_that_replace_the_declarations_module(
})
.collect();
edited.sort();
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1])];
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
want.sort();
assert_eq!(edited, want);
}
#[tokio::test(flavor = "multi_thread")]
async fn go_incoming_calls_include_callers_beyond_the_search() {
let (h, s, _, same, tools, plan9) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let calls =
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
.items
.iter()
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
.collect();
callers.sort();
let mut want = vec![
(same, "x".to_owned(), vec![1]),
(tools, "a".to_owned(), vec![1]),
(plan9.clone(), "boot".to_owned(), vec![1]),
];
want.sort();
assert_eq!(callers, want);
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
let (h, s, _, same, _) = go_modules_harness();
let (h, s, _, same, _, plan9) = go_modules_harness();
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
std::fs::remove_file(&plan9).unwrap();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;