Skip to content

Commit 93877d5

Browse files
committed
Fix Windows nanoserver Docker test failures
Accumulated fixes from CI testing against Windows nanoserver containers: - exists: use tar-based check as primary on Windows (exec `if exist` cannot see tar-created paths due to ContainerUser permissions) - remove: split into separate rmdir/del exec calls (compound `&` operator has unreliable errorlevel propagation on nanoserver) - search: add tar-based fallback with condition_matches() for Windows where findstr/dir cannot access tar-created paths - search: fix has_any() to include has_grep in the check - search: fix findstr probe to check stdout instead of exit code (findstr /? exits 1 on nanoserver but still prints help) - process: retrieve real exit codes via inspect_exec API instead of hardcoding success=true/code=None - process: move PTY resize_exec after start_exec (Docker requires exec to be running before resize is valid) - process: fix proc_kill to not remove process map entry (let reader task cleanup handle removal to avoid race conditions)
1 parent ac4de3a commit 93877d5

File tree

4 files changed

+268
-66
lines changed

4 files changed

+268
-66
lines changed

distant-docker/src/api.rs

Lines changed: 182 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use async_once_cell::OnceCell;
99
use bollard::Docker;
1010
use distant_core::protocol::{
1111
ChangeKind, DirEntry, Environment, FileType, Metadata, PROTOCOL_VERSION, Permissions,
12-
ProcessId, PtySize, SearchId, SearchQuery, SearchQueryTarget, SetPermissionsOptions,
13-
SystemInfo, UnixMetadata, Version,
12+
ProcessId, PtySize, SearchId, SearchQuery, SearchQueryMatch, SearchQueryTarget,
13+
SetPermissionsOptions, SystemInfo, UnixMetadata, Version,
1414
};
1515
use distant_core::{Api, Ctx};
1616
use tokio::sync::RwLock;
@@ -128,6 +128,122 @@ impl DockerApi {
128128
)))
129129
}
130130
}
131+
132+
/// Perform a search using the Docker tar API as a fallback.
133+
///
134+
/// On Windows nanoserver, exec-based search tools (`findstr`, `dir /s /b`) cannot
135+
/// access paths created via the Docker tar API due to `ContainerUser` permission
136+
/// restrictions. This method reads directory listings and file contents through
137+
/// the Docker archive API and performs matching in Rust.
138+
async fn tar_based_search(&self, query: &SearchQuery) -> Vec<SearchQueryMatch> {
139+
use distant_core::protocol::{
140+
SearchQueryContentsMatch, SearchQueryMatchData, SearchQueryPathMatch,
141+
SearchQuerySubmatch,
142+
};
143+
144+
let mut matches = Vec::new();
145+
146+
let path = query
147+
.paths
148+
.first()
149+
.map(|p| p.to_string_lossy().to_string())
150+
.unwrap_or_else(|| ".".to_string());
151+
152+
// List all entries in the search directory via tar
153+
let entries = match utils::tar_list_dir(&self.client, &self.container, &path).await {
154+
Ok(e) => e,
155+
Err(_) => return matches,
156+
};
157+
158+
match query.target {
159+
SearchQueryTarget::Path => {
160+
for (_entry_type, entry_path, _size, _mtime) in &entries {
161+
let filename = std::path::Path::new(entry_path)
162+
.file_name()
163+
.map(|n| n.to_string_lossy().to_string())
164+
.unwrap_or_default();
165+
166+
if search::condition_matches(&query.condition, &filename) {
167+
// Reconstruct the full container path.
168+
// tar_list_dir returns paths like "dirname/file.txt" — strip
169+
// the first component and any leading separator so Path::join
170+
// treats the remainder as relative (not absolute).
171+
let relative = entry_path
172+
.strip_prefix(
173+
std::path::Path::new(entry_path)
174+
.components()
175+
.next()
176+
.map(|c| c.as_os_str().to_string_lossy().to_string())
177+
.unwrap_or_default()
178+
.as_str(),
179+
)
180+
.unwrap_or(entry_path)
181+
.trim_start_matches(['/', '\\']);
182+
let full_path = std::path::Path::new(&path).join(relative);
183+
matches.push(SearchQueryMatch::Path(SearchQueryPathMatch {
184+
path: full_path,
185+
submatches: vec![SearchQuerySubmatch {
186+
r#match: SearchQueryMatchData::Text(filename),
187+
start: 0,
188+
end: 0,
189+
}],
190+
}));
191+
}
192+
}
193+
}
194+
SearchQueryTarget::Contents => {
195+
for (entry_type, entry_path, _size, _mtime) in &entries {
196+
// Skip directories — only search file contents
197+
if *entry_type == tar::EntryType::Directory {
198+
continue;
199+
}
200+
201+
// Reconstruct the full container path for this file
202+
let relative = entry_path
203+
.strip_prefix(
204+
std::path::Path::new(entry_path)
205+
.components()
206+
.next()
207+
.map(|c| c.as_os_str().to_string_lossy().to_string())
208+
.unwrap_or_default()
209+
.as_str(),
210+
)
211+
.unwrap_or(entry_path)
212+
.trim_start_matches(['/', '\\']);
213+
let full_path = std::path::Path::new(&path).join(relative);
214+
let full_path_str = full_path.to_string_lossy().to_string();
215+
216+
// Read file contents via tar API
217+
let data =
218+
match utils::tar_read_file(&self.client, &self.container, &full_path_str)
219+
.await
220+
{
221+
Ok(d) => d,
222+
Err(_) => continue,
223+
};
224+
225+
let text = String::from_utf8_lossy(&data);
226+
for (line_num, line) in text.lines().enumerate() {
227+
if search::condition_matches(&query.condition, line) {
228+
matches.push(SearchQueryMatch::Contents(SearchQueryContentsMatch {
229+
path: full_path.clone(),
230+
lines: SearchQueryMatchData::Text(line.to_string()),
231+
line_number: (line_num + 1) as u64,
232+
absolute_offset: 0,
233+
submatches: vec![SearchQuerySubmatch {
234+
r#match: SearchQueryMatchData::Text(line.to_string()),
235+
start: 0,
236+
end: 0,
237+
}],
238+
}));
239+
}
240+
}
241+
}
242+
}
243+
}
244+
245+
matches
246+
}
131247
}
132248

133249
impl Api for DockerApi {
@@ -525,24 +641,30 @@ impl Api for DockerApi {
525641
async move {
526642
let path_str = path.to_string_lossy().to_string();
527643

528-
let cmd = match self.family {
529-
DockerFamily::Unix => {
530-
if force {
531-
format!("rm -rf '{}'", path_str)
532-
} else {
533-
format!("rm -r '{}'", path_str)
534-
}
535-
}
536-
DockerFamily::Windows => {
537-
// Try rmdir first (for dirs), fall back to del (for files)
538-
format!(
539-
"rmdir /s /q \"{}\" 2>nul & if errorlevel 1 del /f \"{}\"",
540-
path_str, path_str
541-
)
542-
}
543-
};
644+
if self.family == DockerFamily::Unix {
645+
let cmd = if force {
646+
format!("rm -rf '{}'", path_str)
647+
} else {
648+
format!("rm -r '{}'", path_str)
649+
};
650+
return self.run_shell_cmd_stdout(&cmd).await.map(|_| ());
651+
}
544652

545-
self.run_shell_cmd_stdout(&cmd).await.map(|_| ())
653+
// Windows: nanoserver's cmd.exe has limited compound operators —
654+
// `||` is not supported and `& if errorlevel 1` has unreliable
655+
// errorlevel propagation. Try `rmdir /s /q` first (handles
656+
// directories), then `del /f` for files, as separate exec calls.
657+
let rmdir_cmd = format!("rmdir /s /q \"{}\"", path_str);
658+
if self
659+
.run_shell_cmd(&rmdir_cmd)
660+
.await
661+
.is_ok_and(|o| o.success())
662+
{
663+
return Ok(());
664+
}
665+
666+
let del_cmd = format!("del /f /q \"{}\"", path_str);
667+
self.run_shell_cmd_stdout(&del_cmd).await.map(|_| ())
546668
}
547669
}
548670

@@ -627,18 +749,21 @@ impl Api for DockerApi {
627749
async move {
628750
let path_str = path.to_string_lossy().to_string();
629751

630-
// Try exec-based check first
631-
let cmd = match self.family {
632-
DockerFamily::Unix => format!("test -e '{}'", path_str),
633-
DockerFamily::Windows => {
634-
format!("if exist \"{}\" (exit 0) else (exit 1)", path_str)
635-
}
636-
};
752+
if self.family == DockerFamily::Windows {
753+
// On Windows nanoserver, the exec-based `if exist` check is unreliable:
754+
// it cannot see directories created via the Docker tar API (used as
755+
// fallback when `mkdir` fails due to ContainerUser permissions).
756+
// Use the tar-based check as the primary method — Docker's archive
757+
// API correctly reports existence regardless of how the path was created,
758+
// and returns 404 for truly deleted paths.
759+
return Ok(utils::tar_path_exists(&self.client, &self.container, &path_str).await);
760+
}
637761

762+
// Unix: use exec-based check with tar fallback for infrastructure errors
763+
let cmd = format!("test -e '{}'", path_str);
638764
match self.run_shell_cmd(&cmd).await {
639765
Ok(output) => Ok(output.success()),
640766
Err(_) => {
641-
// Fallback to tar-based existence check
642767
Ok(utils::tar_path_exists(&self.client, &self.container, &path_str).await)
643768
}
644769
}
@@ -737,26 +862,28 @@ impl Api for DockerApi {
737862
query: SearchQuery,
738863
) -> impl std::future::Future<Output = io::Result<SearchId>> + Send {
739864
async move {
740-
if !self.search_tools.has_any() {
741-
return Err(io::Error::new(
742-
io::ErrorKind::Unsupported,
743-
"No search tools available in this container. \
744-
Install ripgrep, grep, or find for search support.",
745-
));
746-
}
747-
748-
let cmd = search::build_search_command(&query, &self.search_tools, self.family)?;
749865
let search_id: SearchId = rand::random();
750866

751-
// Run the search command
752-
let output = self.run_shell_cmd(&cmd).await?;
753-
let stdout = output.stdout_str();
867+
// Try exec-based search first if tools are available
868+
let mut matches = Vec::new();
869+
if self.search_tools.has_any()
870+
&& let Ok(cmd) =
871+
search::build_search_command(&query, &self.search_tools, self.family)
872+
&& let Ok(output) = self.run_shell_cmd(&cmd).await
873+
{
874+
let stdout = output.stdout_str();
875+
matches = match query.target {
876+
SearchQueryTarget::Contents => search::parse_contents_matches(&stdout),
877+
SearchQueryTarget::Path => search::parse_path_matches(&stdout),
878+
};
879+
}
754880

755-
// Parse results based on search target
756-
let matches = match query.target {
757-
SearchQueryTarget::Contents => search::parse_contents_matches(&stdout),
758-
SearchQueryTarget::Path => search::parse_path_matches(&stdout),
759-
};
881+
// On Windows nanoserver, exec-based search tools (findstr, dir) cannot
882+
// access paths created via the Docker tar API. Fall back to a tar-based
883+
// search that reads files through the Docker archive API.
884+
if matches.is_empty() && self.family == DockerFamily::Windows {
885+
matches = self.tar_based_search(&query).await;
886+
}
760887

761888
// Send results via reply
762889
use distant_core::protocol::Response;
@@ -870,12 +997,20 @@ impl Api for DockerApi {
870997
let mut processes = self.processes.write().await;
871998
match processes.get_mut(&id) {
872999
Some(process) => {
873-
// Send kill signal via channel
1000+
// Take the kill channel to send the signal. We do NOT remove the
1001+
// map entry here — the reader task's cleanup closure is the sole
1002+
// owner of removal. Removing here creates a race: if a new process
1003+
// reuses the same ProcessId before the reader finishes, the stale
1004+
// cleanup would delete the new entry.
8741005
if let Some(kill_tx) = process.kill_tx.take() {
8751006
let _ = kill_tx.send(()).await;
1007+
Ok(())
1008+
} else {
1009+
Err(io::Error::new(
1010+
io::ErrorKind::InvalidInput,
1011+
format!("Process {} has already been killed", id),
1012+
))
8761013
}
877-
processes.remove(&id);
878-
Ok(())
8791014
}
8801015
None => Err(io::Error::new(
8811016
io::ErrorKind::NotFound,

distant-docker/src/process.rs

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ where
132132
let stderr_reply = reply.clone_reply();
133133
let exit_reply = reply;
134134
let msg_id = id;
135+
let exit_client = client.clone();
136+
let exit_exec_id = exec_id.clone();
135137

136138
// Reader task: forwards stdout/stderr to the client
137139
tokio::spawn(async move {
@@ -157,11 +159,25 @@ where
157159
}
158160
}
159161

160-
// Process has exited
162+
// Retrieve the real exit code from the Docker exec API
163+
let (success, code) = match exit_client.inspect_exec(&exit_exec_id).await {
164+
Ok(inspect) => {
165+
let exit_code = inspect.exit_code.unwrap_or(-1);
166+
(exit_code == 0, Some(exit_code as i32))
167+
}
168+
Err(e) => {
169+
warn!(
170+
"Failed to inspect exec {} for process {}: {}",
171+
exit_exec_id, msg_id, e
172+
);
173+
(false, None)
174+
}
175+
};
176+
161177
let _ = exit_reply.send(Response::ProcDone {
162178
id: msg_id,
163-
success: true,
164-
code: None,
179+
success,
180+
code,
165181
});
166182

167183
cleanup(msg_id).await;
@@ -268,18 +284,6 @@ where
268284

269285
let exec_id = created.id.clone();
270286

271-
// Resize the PTY to the requested size before starting
272-
client
273-
.resize_exec(
274-
&exec_id,
275-
ResizeExecOptions {
276-
height: size.rows,
277-
width: size.cols,
278-
},
279-
)
280-
.await
281-
.map_err(|e| io::Error::other(format!("Failed to resize PTY: {}", e)))?;
282-
283287
let start_result = client
284288
.start_exec(
285289
&created.id,
@@ -291,6 +295,20 @@ where
291295
.await
292296
.map_err(|e| io::Error::other(format!("Failed to start PTY process: {}", e)))?;
293297

298+
// Resize the PTY after starting — the Docker API requires the exec to be
299+
// running before resize_exec is valid. This is best-effort; if it fails,
300+
// the writer task's resize handler will correct the size on the next
301+
// client-initiated resize.
302+
let _ = client
303+
.resize_exec(
304+
&exec_id,
305+
ResizeExecOptions {
306+
height: size.rows,
307+
width: size.cols,
308+
},
309+
)
310+
.await;
311+
294312
let id: ProcessId = rand::random();
295313

296314
let (stdin_tx, mut stdin_rx) = mpsc::channel::<Vec<u8>>(32);
@@ -305,6 +323,8 @@ where
305323
let stdout_reply = reply.clone_reply();
306324
let exit_reply = reply;
307325
let msg_id = id;
326+
let exit_client = client.clone();
327+
let exit_exec_id = exec_id.clone();
308328

309329
// Reader task: PTY combines stdout+stderr into one stream
310330
tokio::spawn(async move {
@@ -325,10 +345,25 @@ where
325345
}
326346
}
327347

348+
// Retrieve the real exit code from the Docker exec API
349+
let (success, code) = match exit_client.inspect_exec(&exit_exec_id).await {
350+
Ok(inspect) => {
351+
let exit_code = inspect.exit_code.unwrap_or(-1);
352+
(exit_code == 0, Some(exit_code as i32))
353+
}
354+
Err(e) => {
355+
warn!(
356+
"Failed to inspect exec {} for PTY process {}: {}",
357+
exit_exec_id, msg_id, e
358+
);
359+
(false, None)
360+
}
361+
};
362+
328363
let _ = exit_reply.send(Response::ProcDone {
329364
id: msg_id,
330-
success: true,
331-
code: None,
365+
success,
366+
code,
332367
});
333368

334369
cleanup(msg_id).await;

0 commit comments

Comments
 (0)