Skip to content

fix(deps): keep child processes from inheriting listening sockets on Windows - #144

Closed
TheMrDr wants to merge 1 commit into
timhartmann7:mainfrom
TheMrDr:fix/windows-tunnel-port-child-inheritance
Closed

TheMrDr wants to merge 1 commit into
timhartmann7:mainfrom
TheMrDr:fix/windows-tunnel-port-child-inheritance

Conversation

@TheMrDr

@TheMrDr TheMrDr commented Oct 2, 2026

Copy link
Copy Markdown

Problem

On Windows, a tunnel can be left permanently unable to rebind its ports after
an SSH key setup runs.

Setting up a key spawns ssh-keygen through tokio::process::Command
(key_setup.rs:280). On Windows that child inherits every socket this process
has open at the moment it starts — including the ports a tunnel is listening on —
and holds them for its own lifetime. Stop a tunnel and start it again inside that
window and bind_all fails with:

Only one usage of each socket address (protocol/network address/port) is normally permitted. (os error 10048)

serve() in tunnel.rs:352 turns a bind failure into TunnelStatus::Failed and
returns without retrying, so the tunnel stays down until the app is restarted. A
host set to start its tunnel with OmnySSH can also fail to come up at all if a key
setup happens to be running.

Unix was never affected — Tokio's sockets are close-on-exec there.

Cause

Not in this repo. mio created sockets without asking Windows to keep them out of
child processes. mio 1.2.1 fixed it by setting WSA_FLAG_NO_HANDLE_INHERIT on
socket creation (tokio-rs/mio#1946).
The lockfile pins 1.2.0.

Evidence

I did not want to infer this from flaky tests, so there are two independent
measurements.

1. The test suite, before and after, same machine and same base commit.

--test tunnel, 10 runs --lib, 25 runs
mio 1.2.0 41 failures (4–5 per run, 10/10 runs red) 1–4 runs red
mio 1.2.3 10 failures (exactly 1 per run) 0 runs red

The 10 remaining tunnel failures are all one test,
a_rejected_password_fails_without_a_retry, which fails for an unrelated reason —
see Not fixed here below. The --lib test that stops failing is
a_port_in_use_fails_the_whole_tunnel_and_binds_nothing.

2. A standalone reproduction of the mechanism itself, so the conclusion does
not rest on the suite. Bind a tokio TcpListener, spawn a child that outlives it,
drop the listener, then try to rebind the same port:

mio = 1.2.0
  trial 1: port 64716 REFUSED while the child was alive -- os error 10048
           port 64716 is free once the child exits
  ... 5/5 trials refused
VERDICT: child processes inherit the listening socket handle.

mio = 1.2.1   0/5 refused
mio = 1.2.2   0/5 refused
mio = 1.2.3   0/5 refused

The port freeing the instant the child exits is what identifies the child as the
holder. 1.2.1 is the minimum fixed version; I went to 1.2.3, the current patch
of the same minor, and am happy to use 1.2.1 instead if you would rather take the
smallest possible step.

Why the lockfile diff is only two lines

cargo update -p mio --precise 1.2.3 also rewrites which windows-sys version
fourteen other crates resolve to. That churn is pre-existing lockfile drift, not
caused by this bump
: asking cargo to "update" mio to 1.2.0 — the version already
pinned — produces the same 26 lines. The set of windows-sys versions in the graph
is identical before and after.

So this edits only mio's version and checksum, and leaves the drift alone
rather than smuggling it into a bug fix. cargo build --locked accepts the result
without rewriting it. If you would prefer the lockfile normalized in the same PR,
say so and I will add it as a second commit you can drop independently.

Possible overlap with #134

#134 already bumps mio 1.2.0 → 1.2.3, incidentally, as part of its dependency
updates. Nothing in that PR mentions Windows, sockets, tunnels or handle
inheritance, so I don't think it is known there that the bump fixes a bug — worth
flagging either way:

Your call entirely — I am not attached to this being the PR that carries it.

Not fixed here

Three tests fail on Windows before and after this change, for a different reason,
so the suite is not green on Windows even with this applied:

  • tests/login.rs::a_login_with_nothing_to_try_says_what_is_missing
  • tests/login.rs::a_refused_password_is_sent_once_per_login
  • tests/tunnel.rs::a_rejected_password_fails_without_a_retry

All three fail with SSH key requires a passphrase: C:\Users\<me>\.ssh\id_ed25519.
Their isolate_home() helpers set HOME and USERPROFILE to a temp dir, which
works for known_hosts (that path uses std::env::home_dir()), but
default_key_paths() in session.rs goes through dirs::home_dir(), and in
dirs 5.0.1 on Windows that resolves via the Shell known-folder API and ignores
%USERPROFILE%. So the tests read the developer's real ~/.ssh. CI does not see
it because its Windows runner has no keys.

Happy to open that separately if you want it — it needs a seam in core rather than
a test-only change, so it seemed worth asking first.

Test plan

  • cargo build --locked — clean, lockfile not rewritten
  • cargo test --locked --no-fail-fast — only the three passphrase tests above fail
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • bash scripts/hygiene-guard.sh — passes
  • git diff --exit-code doc/omny.1 — clean
  • The before/after runs tabulated above, on Windows 11, mio 1.2.0 vs 1.2.3

No source files change, so there is nothing new to unit-test; the behaviour is in a
dependency and the reproduction above is what demonstrates it.

🤖 Generated with Claude Code

…Windows

Setting up an SSH key spawns ssh-keygen through tokio::process::Command
(key_setup.rs). On Windows that child inherited every socket this process
had open when it started, including the ports a tunnel listens on, and
held them for its own lifetime. Stopping a tunnel and starting it again
in that window failed to bind with os error 10048, and since serve() in
tunnel.rs turns a bind failure into TunnelStatus::Failed without a retry,
the tunnel stayed down until the app was restarted.

The cause is in mio, which created sockets without asking Windows to keep
them out of child processes. mio 1.2.1 sets WSA_FLAG_NO_HANDLE_INHERIT on
socket creation (tokio-rs/mio#1946); this moves the lockfile to 1.2.3, the
current patch of the same minor. Unix was never affected, since Tokio's
sockets are close-on-exec there.

Measured on Windows 11, before and after, same machine and same base:

  cargo test -p omnyssh-core --test tunnel, 10 runs
    mio 1.2.0   41 failures (4-5 per run, 10/10 runs red)
    mio 1.2.3   10 failures (exactly 1 per run, all of them the unrelated
                a_rejected_password_fails_without_a_retry)

  cargo test -p omnyssh-core --lib, 25 runs
    mio 1.2.0   1-4 runs red on a_port_in_use_fails_the_whole_tunnel_and_binds_nothing
    mio 1.2.3   0 runs red

A standalone reproduction confirms the mechanism rather than inferring it:
bind a tokio TcpListener, spawn a child that outlives it, drop the
listener, then rebind the same port. On mio 1.2.0 that is refused 5 of 5
times with os error 10048 and the port frees the moment the child exits;
on 1.2.1, 1.2.2 and 1.2.3 it succeeds 5 of 5. 1.2.1 is the minimum fixed
version.

Only mio's version and checksum change. Running `cargo update -p mio`
would also rewrite which windows-sys version fourteen other crates
resolve to, but that churn is pre-existing lockfile drift: asking cargo
to "update" mio to 1.2.0, the version already pinned, produces the same
26 lines. This edits the two lines that matter and leaves the drift for
whoever wants to address it separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TheMrDr TheMrDr closed this Oct 4, 2026
@TheMrDr
TheMrDr deleted the fix/windows-tunnel-port-child-inheritance branch October 4, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant