fix(wsl): send a pane to the remote's pane socket

A remote `tty7-server` listens twice and the two dialects are not
interchangeable; which socket a routed stream lands on is decided by
`--pane` on the bridge command. The SSH path adds it via
`RouteChannel::bridge_command`, and `LocalStdio` adds it in the client's
`PaneWorkspace::route_header` because its argv is run verbatim — but
`RemoteLink::wsl` always built `[binary, "--stdio"]` and dropped
`setup.channel` on the floor.

So a WSL pane reached the control socket, wrote its `Spawn`, and was
answered with nothing: `routed connection closed after 56 up / 0 down
bytes`. The workspace connected, the window opened, and the pane inside
it said it could not reach the machine. `PaneWorkspace::route_header`
already documents this exact failure shape; WSL was the transport that
never got wired to it.

WSL is the one transport that builds its own argv, so the choice belongs
where the argv is built. `wsl_shell` routes its override through
`bridge_command`, the same rewrite SSH applies to its shell command, so
an escape hatch cannot silently lose the dialect.

The argv is now a pure `wsl_link_argv`, because the difference between
the two is one flag that cannot be checked anywhere a distribution is
required.
This commit is contained in:
l0ng-ai
2026-07-30 13:38:22 +08:00
committed by l0ng-ai
parent a45830ae80
commit b6577d10a6
2 changed files with 61 additions and 6 deletions
+56 -4
View File
@@ -55,6 +55,7 @@ use russh::Channel;
use russh::client::Msg;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use super::router::RouteChannel;
use super::ssh::ProcessStream;
/// One logical stream between the local daemon and a remote `tty7-server`.
@@ -115,9 +116,21 @@ impl RemoteLink {
/// No shell is involved, so `server` needs no quoting; the distro name is
/// validated because it is an *option's* argument and a leading `-` would be
/// read as another option.
pub fn wsl(distro: &str, server: &str) -> io::Result<RemoteLink> {
///
/// # `channel`
///
/// **A pane bridge and a control bridge are different commands**, and this
/// is the only place that can tell them apart for WSL. The remote listens
/// twice and the dialects are not interchangeable — a pane landing on the
/// control socket writes its `Spawn` and is answered with nothing, which is
/// what "the workspace connects but the pane says it can't reach the
/// machine" was. The SSH path makes the same choice in
/// [`ssh::open_remote_link`](crate::daemon::ssh), and `LocalStdio` makes it
/// in the client's `PaneWorkspace::route_header` because its argv is run
/// verbatim; WSL builds its argv here, so here is where it belongs.
pub fn wsl(distro: &str, server: &str, channel: RouteChannel) -> io::Result<RemoteLink> {
super::install::wsl::validate_distro(distro)?;
let args = super::install::wsl::wsl_args(distro, &[server, "--stdio"]);
let args = super::install::wsl::wsl_args(distro, &wsl_link_argv(server, channel));
Ok(RemoteLink::Wsl(spawn_stdio_owned(
super::install::wsl::WSL_EXE,
&args,
@@ -131,9 +144,15 @@ impl RemoteLink {
///
/// The escape hatch for a distribution where the normal install path cannot
/// be used; the resolved-path form above is what ships.
pub fn wsl_shell(distro: &str, command: &str) -> io::Result<RemoteLink> {
///
/// `channel` reaches the command through
/// [`RouteChannel::bridge_command`], which is the same rewrite the SSH path
/// applies to *its* shell command — an override must not silently lose the
/// pane dialect that [`RemoteLink::wsl`] gets right.
pub fn wsl_shell(distro: &str, command: &str, channel: RouteChannel) -> io::Result<RemoteLink> {
super::install::wsl::validate_distro(distro)?;
let args = super::install::wsl::wsl_args(distro, &["sh", "-c", command]);
let command = channel.bridge_command(command);
let args = super::install::wsl::wsl_args(distro, &["sh", "-c", &command]);
Ok(RemoteLink::Wsl(spawn_stdio_owned(
super::install::wsl::WSL_EXE,
&args,
@@ -175,6 +194,19 @@ impl RemoteLink {
}
}
/// The command `wsl.exe` runs for a link on `channel`, as an argv.
///
/// Pure, because the difference between the two is one flag that decides which
/// of the remote's two sockets the stream lands on, and it cannot be checked
/// anywhere a distribution is required. See [`RemoteLink::wsl`].
fn wsl_link_argv<'a>(server: &'a str, channel: RouteChannel) -> Vec<&'a str> {
let mut argv = vec![server, "--stdio"];
if channel == RouteChannel::Pane {
argv.push("--pane");
}
argv
}
fn spawn_stdio(program: &str, args: &[&str]) -> io::Result<ProcessStream> {
let owned: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
spawn_stdio_owned(program, &owned)
@@ -468,6 +500,26 @@ mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// **A WSL pane asks for the pane socket.** The remote listens twice and
/// answers only the dialect it was asked for, so a pane that reaches the
/// control socket writes its `Spawn` and is answered with nothing at all —
/// the workspace connects, the window opens, and the pane inside it says it
/// cannot reach the machine. Nothing in the transport reports an error,
/// which is why this is pinned here rather than left to the one integration
/// path that would catch it.
#[test]
fn only_a_pane_link_asks_for_the_pane_socket() {
let server = "/home/me/.local/share/tty7/bin/tty7-server-26.7.6";
assert_eq!(
wsl_link_argv(server, RouteChannel::Control),
vec![server, "--stdio"]
);
assert_eq!(
wsl_link_argv(server, RouteChannel::Pane),
vec![server, "--stdio", "--pane"]
);
}
/// A child process's stdio really is a duplex stream: bytes written reach
/// the child's stdin and its stdout comes back, through the same
/// `AsyncRead`/`AsyncWrite` the SSH variants use. This is the path the
+5 -2
View File
@@ -1185,9 +1185,12 @@ async fn open_link(
)
}
};
// `setup.channel`, not `Control`: which of the remote's two sockets
// this stream is for is carried by the header, and WSL is the one
// transport that builds its own argv — see [`RemoteLink::wsl`].
let link = match (header.server_command.as_deref(), resolved.as_deref()) {
(Some(command), _) => RemoteLink::wsl_shell(distro, command)?,
(None, Some(binary)) => RemoteLink::wsl(distro, binary)?,
(Some(command), _) => RemoteLink::wsl_shell(distro, command, setup.channel)?,
(None, Some(binary)) => RemoteLink::wsl(distro, binary, setup.channel)?,
(None, None) => unreachable!("resolved is Some whenever there is no override"),
};
Ok((link, None))