Skip to content

Follow symlinks to directories in WSL Tab completion, plus a host-attribute probe (APP-3993) - #14755

Open
warp-agent-staging[bot] wants to merge 5 commits into
masterfrom
factory/app-3993-wsl-symlink-logging
Open

Follow symlinks to directories in WSL Tab completion, plus a host-attribute probe (APP-3993)#14755
warp-agent-staging[bot] wants to merge 5 commits into
masterfrom
factory/app-3993-wsl-symlink-logging

Conversation

@warp-agent-staging

@warp-agent-staging warp-agent-staging Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes WSL Tab completion of a symlink that points at a directory (cd link<Tab> completed it as a file instead of a directory). This is the WSL half of #4498; the remote/SSH half was fixed in #14746 (merged) and plain local macOS/Linux was already correct, so this completes the issue.

Mechanism (confirmed from instrumented logs on the reporter's real WSL host): a WSL session lists via SessionType::Local, and std::fs::read_dir correctly reports the entry as a symlink, but value.path().metadata() returns Err(NotFound) — the target is an IO_REPARSE_TAG_LX_SYMLINK that the Windows host cannot follow over the \\wsl$ (Plan 9) filesystem. std::fs::read_link can't read that reparse tag either (Rust std only handles IO_REPARSE_TAG_SYMLINK / MOUNT_POINT), so the old unwrap_or(false) bucketed the symlink as a file. (A prior host-side fallback shipped under APP-5190 failed for exactly this reason and was removed.)

Fix (guest-side, Windows-only): the WSL mechanism lives in app/src/completer/wsl_symlinks.rs, compiled only under #[cfg(windows)]. After the host std::fs listing, if the session is WSL and at least one symlink was left unclassified as a directory, it asks the guest which immediate children are directories with cd <dir> && find -L . -maxdepth 1 -type d -print0 — the same -L shape that fixed the remote path in #14746 — and upgrades the matching symlink entries. Nothing from the listing is interpolated into the command, so there is no filename-quoting or injection surface. Every non-Windows target keeps the exact host listing it had before this PR, and broken links / loops stay non-directories.

Also in this PR: a probe that may let the guest round trip be deleted entirely. See "Open question" below — the fix as written is what ships if the probe comes back negative.

Open question — can a host-only check replace the guest round trip?

std::os::windows::fs::FileTypeExt::is_symlink_dir() was never evaluated when this fix was written. In std's Windows implementation it reduces to is_symlink() && is_directory(), and is_directory() reads the FILE_ATTRIBUTE_DIRECTORY bit on the link entry itself — it never follows the target, so the NotFound that kills metadata() does not apply to it. Verified against the pinned toolchain's source (rust 1.92.0, library/std/src/sys/fs/windows.rs):

  • FileType::new(attributes, reparse_tag) sets is_directory = attributes & FILE_ATTRIBUTE_DIRECTORY != 0 and is_symlink = FILE_ATTRIBUTE_REPARSE_POINT && (reparse_tag & 0x20000000) (the name-surrogate bit).
  • is_dir() is !is_symlink && is_directoryfalse by construction for any name-surrogate reparse point, which is why the existing instrumentation could never answer this.
  • is_symlink_dir() is is_symlink && is_directory; is_symlink_file() is is_symlink && !is_directory.
  • DirEntry::file_type() is built entirely from the WIN32_FIND_DATAW the enumeration already returned, so on Windows that check costs no syscall at all.

What is not established: whether the \\wsl$ redirector actually sets FILE_ATTRIBUTE_DIRECTORY on an IO_REPARSE_TAG_LX_SYMLINK. That is a property of the redirector, not of std, and no runner in the fleet can execute it. This PR does not answer it — the reporter's next dogfood run does.

So this PR adds a probe (Windows-only, debug-gated, behavior-preserving) that records, for every symlink entry, the raw evidence:

  • is_symlink_dir() / is_symlink_file() from the directory enumeration (entry_*) and from an fs::symlink_metadata() of the link (link_*) — a redirector may fill the two differently, and symlink_metadata opens the link with FILE_FLAG_OPEN_REPARSE_POINT, which can itself fail, so its failure is recorded too;
  • the raw MetadataExt::file_attributes() bits, so the answer does not rest on a derived boolean;
  • the existing read_link outcome, retained deliberately so the same run records its real behavior instead of leaving us on inference.

If that run shows FILE_ATTRIBUTE_DIRECTORY set, the fix collapses to a couple of lines in EngineDirEntry::try_from — no guest command, no 3s timeout, no shell on the typing hot path — which is strictly better. Until then the guest round trip stays.

Rework changes

Three review findings addressed in one cycle.

  1. 🚨 wasm CI regression (blocking) — fixed. Awaiting with_timeout made PathCompletionContext::list_directory_entries non-Send: on wasm, warpui::r#async::Timer holds a Pin<Box<dyn Future<Output = Instant>>> with no Send bound, and #[async_trait] requires a Send future, so Formatting + Clippy (wasm) and Verify compilation with release flags (wasm) both failed with E0277. The whole guest mechanism moved out of app/src/completer/mod.rs into a new #[cfg(windows)] mod wsl_symlinks, so no non-Windows target compiles the timeout at all; the SessionType::Local arm now selects between the WSL path and the original upstream one-line listing with cfg_if!. Both wasm gates verified green locally — see Testing.
  2. ⚠️ MSYS2 in the gate — removed. The condition is now is_wsl() only. There is no MSYS2 report, log, or repro on this issue, APP-5190, or Follow symlinks with Tab autocomplete (Linux client) #4498 — it was added for symmetry with WSL, not from evidence. It is also unlikely to be affected: with MSYS=winsymlinks:nativestrict MSYS2 creates real NTFS symlinks the host follows correctly (so metadata() succeeds and the entry is never a candidate), and its default copy / :lnk / :sys modes produce plain files or directories that are not reparse points at all (so file_type.is_symlink() is false and the entry is never a candidate either). Keeping it would have run an unverified guest command in an out-of-scope environment for no benefit.
  3. ⚠️ Comment policy — re-audited across the whole branch diff. The five-line probe narration is gone, as is the narration on the moved private helpers. Six comments remain on the entire branch, each carrying only rationale the code cannot express: the host/guest premise, the None-degrades-rather-than-stalls contract, the -L and injection-surface reasoning, the . entry find emits, the guard that only unresolved symlinks are upgraded, and the probe's purpose. No comments remain in any test file.

The probe's behavior-preserving property and the read_link line are unchanged.

Linked Issue

Closes #4498

  • The linked issue is labeled ready-to-spec or ready-to-implement.
  • Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes).

Testing

What was executed here

  • Both previously-failing wasm gates now pass locally, running the exact CI commands:
    • cargo clippy --locked --target wasm32-unknown-unknown --profile release-wasm-debug_assertions -- -D warnings → exit 0
    • ./script/wasm/bundle --channel oss --nouniversal --check-only → exit 0 (the only warp lib warning is pre-existing, in app/src/terminal/model/grid/resize.rs)
  • ./script/format --check; ./script/check_no_inline_test_modules; cargo clippy -p warp --all-targets --tests -- -D warnings; cargo clippy -p warp_completer --all-targets --tests -- -D warnings — all pass.
  • cargo nextest run -p warp completer:: (12/12) and cargo nextest run -p warp_completer engine::path (19/19).
  • crates/warp_completer/.../path_tests.rs::test_engine_dir_entry_classifies_symlink_targets (behavior preservation): try_from still classifies a symlink-to-dir as Directory and a symlink-to-file as File. The probe only reads; classification is metadata() Okis_dir(), Errfalse, exactly as before.
  • The guest-classification unit tests (test_parse_directory_names, test_upgrade_directory_symlinks — the latter is the regression for the Err-from-metadata path) moved into wsl_symlinks_tests.rs with the module, so they are Windows-only now and run in CI's Run Windows tests job rather than in the Linux/macOS runs. That matches the mechanism they cover, which only exists on Windows.
  • The probe is #[cfg(windows)], so Linux clippy does not cover it. It was compile-checked separately against real Windows std for the pinned toolchain (rustc --target x86_64-pc-windows-msvc, edition 2024, including the call-site borrow/cfg shape) — clean, no warnings. CI's Windows Clippy job also compiles it.
  • Latency: the guest round trip is .awaited on the async completion task, exactly like the sibling SessionType::WarpifiedRemote branch of the same function which already awaits a guest find for every remote path completion, so it does not block the render/UI thread. It is bounded with with_timeout (3s); on timeout, error, or non-success it returns the host classification (today's behavior) rather than stalling, and list_directory_entries caches the result.

What could NOT be executed

No Windows or WSL runner exists in the fleet, so the WSL code path was never run. Nothing here should be read as verification that WSL Tab completion is fixed, or as an answer to the is_symlink_dir() question above. The reporter's dogfood run is the only real verification.

  • I have manually tested my changes locally with ./script/run

Steps for the reporter

  1. Build a dogfood build of this branch (factory/app-3993-wsl-symlink-logging, head 79293ba). Dogfood matters: the safe_* macros only emit the detailed full: arm on dogfood; a release build logs the redacted arm and hides the path.
  2. Launch Warp with the probe enabled: set RUST_LOG=warp_completer::completer::engine::path=debug,warp::completer=debug in the environment before starting Warp.exe (the default level is Info, so the probe is silent without this).
  3. In a WSL session: mkdir realdir && ln -s realdir linkdir, then type cd link and press Tab. Note whether linkdir/ completes as a directory (trailing separator, offered for cd).
  4. Send back the [APP-3993 symlink-completion] lines from warp.log (or the whole log-bundle zip) around that Tab press. One line per symlink entry, of the form:
    [APP-3993 symlink-completion] path=… metadata=Err(NotFound) read_link=Err(…) entry_symlink_dir=… entry_symlink_file=… link_symlink_dir=… link_symlink_file=… link_attributes=0x…
    The decisive fields are entry_symlink_dir / link_symlink_dir and the raw link_attributes for linkdir. 0x10 (FILE_ATTRIBUTE_DIRECTORY) set in link_attributes means the guest round trip can be deleted and replaced with a host-only check.

Screenshots / Videos

Not applicable — no rendered UI surface, and the affected path cannot be exercised on any runner here.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-BUG-FIX: Tab autocomplete now follows symlinks to directories in WSL sessions.
Refs #4498
Originating thread: https://warpdev.slack.com/archives/C0BDQDW8V5E/p1785963086148259

Conversation: https://staging.warp.dev/conversation/fc706517-d258-4bba-abdf-5e503c445682
Run: https://oz.staging.warp.dev/runs/019fd965-3ef5-72dc-b946-89c45e8d8063

This PR was generated with Oz.

Instrumentation only, not a fix. WSL symlinked directories do not tab-complete
as directories, but no factory runner can execute a WSL path, so this adds
debug-level, greppable diagnostics so the requester can build a dogfood build,
reproduce on their WSL host, and return logs that reveal the mechanism.

Instruments the local directory-listing path:
- app/src/completer/mod.rs list_directory_entries_internal: the SessionType
  branch taken, the guest directory vs the converted native host path, the
  read_dir error (kind) instead of swallowing it, and the resulting entry count.
- crates/warp_completer path.rs EngineDirEntry::try_from: the raw
  is_dir/is_file/is_symlink flags per entry, and — in the symlink branch —
  replaces unwrap_or(false) with a match that logs metadata() Ok(is_dir) vs
  Err(kind), preserving the identical classification result.

All lines are debug level (off by default), use safe_* for anything carrying a
path or file name (redacted in release), and share the prefix
[APP-3993 symlink-completion]. Classification behavior is unchanged; a new
warp_completer test asserts try_from still classifies a local symlink-to-dir as
Directory and a symlink-to-file as File.

Co-Authored-By: Warp Agent <agent@warp.dev>
@cla-bot cla-bot Bot added the cla-signed label Aug 6, 2026
@warp-agent-staging warp-agent-staging Bot added the warpy-factory Label associated to the "Wob the Wuilder" factory on staging, also known as Warpy Factory. label Aug 6, 2026
@warp-agent-staging
warp-agent-staging Bot marked this pull request as ready for review August 6, 2026 00:18
@warp-agent-staging
warp-agent-staging Bot requested a review from acarl005 August 6, 2026 00:18
…n round-trip

Reporter logs (from the instrumentation this branch added) established the mechanism: a WSL
session lists via SessionType::Local, the entry is a symlink, but value.path().metadata()
returns Err(NotFound) because the target is an IO_REPARSE_TAG_LX_SYMLINK the Windows host cannot
follow. std::fs::read_link cannot read that reparse tag either (Rust std only handles
IO_REPARSE_TAG_SYMLINK / MOUNT_POINT), so host-side resolution is impossible and unwrap_or(false)
bucketed the symlink as a file.

Fix (surgical, guest-side): after the host std::fs listing, if the session is emulated (WSL/MSYS2)
and any symlink was left unclassified as a directory, ask the guest which immediate children are
directories with `cd <dir> && find -L . -maxdepth 1 -type d -print0` (the same -L shape that fixed
the remote path in #14746) and upgrade the matching symlink entries. Nothing from the listing is
interpolated into the command, so there is no filename-quoting or injection surface.

Latency: the command runs only for emulated sessions and only when an unresolved directory symlink
is present; it is awaited on the async completion path (the sibling WarpifiedRemote branch already
awaits a guest command here) and bounded with with_timeout, degrading to the host classification
(symlink shown as a file) on timeout/error rather than stalling completion. Results are cached.
Plain local macOS/Linux is unaffected.

Keeps one temporary debug-gated read_link probe so the reporter's verification run also records
read_link's actual result on their WSL host. Adds unit tests for the output parsing and the
directory-symlink upgrade (the Err-from-metadata case); the local try_from behavior-preservation
test is retained.

Co-Authored-By: Warp Agent <agent@warp.dev>
@warp-agent-staging warp-agent-staging Bot changed the title Add diagnostic logging for WSL symlink completion (APP-3993) Follow symlinks to directories in WSL Tab completion (APP-3993) Aug 6, 2026
oz-agent and others added 2 commits August 6, 2026 23:50
…nd trip

`FileTypeExt::is_symlink_dir()` reads FILE_ATTRIBUTE_DIRECTORY on the link
entry itself, so it is not affected by the NotFound that `metadata()` hits on a
WSL LX symlink over \\wsl$. Whether that bit is actually set there is unknown,
and the existing log cannot answer it: std's `FileType::is_dir()` is
`!is_symlink() && is_directory()`, false by construction for any name-surrogate
reparse point.

Record, for every symlink entry on Windows, `is_symlink_dir()` /
`is_symlink_file()` from both the directory enumeration and an
`fs::symlink_metadata()` of the link, plus the raw
`MetadataExt::file_attributes()` bits. The guest round trip stays in place
until a dogfood run reports back.

Classification is unchanged: `metadata()` Ok still yields `is_dir()` and Err
still yields false.

Co-Authored-By: Warp Agent <agent@warp.dev>
@warp-agent-staging warp-agent-staging Bot changed the title Follow symlinks to directories in WSL Tab completion (APP-3993) Follow symlinks to directories in WSL Tab completion, plus a host-attribute probe (APP-3993) Aug 7, 2026
…tion

The wasm `Timer` holds a non-Send future, so awaiting `with_timeout` made
`PathCompletionContext::list_directory_entries` non-Send and broke both wasm
CI gates. Move the guest classification into a `cfg(windows)` module, leaving
every other target with the plain host listing it had before.

Narrow the runtime gate to `is_wsl()`. There is no MSYS2 report or repro on
this issue, and MSYS2's symlink modes are either real NTFS symlinks the host
resolves or plain files that never reach this path, so the guest round trip
would have run there for nothing.

Co-Authored-By: Warp Agent <agent@warp.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed warpy-factory Label associated to the "Wob the Wuilder" factory on staging, also known as Warpy Factory.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow symlinks with Tab autocomplete (Linux client)

1 participant