Skip to content

transport: probe Windows named pipes without a Tokio reactor - #1525

Open
gdamprint-cmyk wants to merge 2 commits into
1jehuang:masterfrom
gdamprint-cmyk:pr/transport-reactor-once
Open

gdamprint-cmyk wants to merge 2 commits into
1jehuang:masterfrom
gdamprint-cmyk:pr/transport-reactor-once

Conversation

@gdamprint-cmyk

Copy link
Copy Markdown

Closes #1524

Problem

is_socket_path and remove_socket are synchronous by contract, so a caller can
ask "is a daemon already listening?" without owning a runtime. Both nevertheless
opened the pipe through tokio's named-pipe client, which registers the handle
with the ambient reactor:

thread '<unnamed>' panicked at tokio-1.49.0/src/net/windows/named_pipe.rs:1005
there is no reactor running, must be called from the context of a Tokio 1.x runtime

The panic happens on the open call itself, before any result is known, so it
is not specific to a missing or busy endpoint. Any embedder that drives the SDK
from plain std::threads takes this path, which is what Jcode Desktop's bridge
processes do.

It also cannot be caught off Windows: windows.rs compiles only on Windows, and
the path is only reached from a reactor-less thread.

Fix

Both probes go through one probe_pipe helper built on std::fs::OpenOptions,
the same approach SyncStream::connect in this file already uses:

fn probe_pipe(pipe_name: &str) -> io::Result<()> {
    std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .open(pipe_name)
        .map(|_handle| ())
}

ERROR_PIPE_BUSY still maps to "a server is listening", so
busy_pipe_is_reported_as_a_live_socket_path is unchanged and keeps passing.

One file, no API change, no behavioural change on the success and busy paths.

Test

sync_pipe_probes_do_not_panic_without_a_tokio_reactor binds a real listener
inside a #[tokio::test] runtime and then probes it from a std::thread that
deliberately has no runtime. That split is the situation in the bug: the server
owns a runtime, the synchronous probes do not.

Edge cases and the two ways this test can lie

Both were hit while writing it, and both are worth stating because either one
yields a test that passes with and without the fix:

  • Probe a pipe that is actually bound. On an absent path the open fails
    before tokio reaches the reactor registration, so nothing panics. The first
    version of this test did exactly that and passed against the unfixed code.
  • Bind the listener under a runtime. A plain #[test] cannot call
    Listener::bind at all, so the panic then happens during setup rather than in
    the code under test, which makes the test fail for the wrong reason.

With the fix reverted, the failure is the reactor panic in the spawned
thread
(thread '<unnamed>'), not in the setup, and the other six tests in
the crate pass in both states. That is the evidence the test isolates this
defect rather than merely noticing that something changed.

cargo test -p jcode-transport --lib is 7 passed, 0 failed. cargo fmt --check
is clean for the crate.

`is_socket_path` and `remove_socket` are synchronous by contract, but both
opened the pipe with `ClientOptions::new().open()`. Tokio's named-pipe client
registers the pipe handle with the ambient reactor, so calling either from a
thread that has no runtime context panics:

```
thread '<unnamed>' panicked at tokio-1.49.0/src/net/windows/named_pipe.rs:1005
there is no reactor running, must be called from the context of a Tokio 1.x runtime
```

Jcode Desktop drives the SDK from plain `std::thread`s, so its bridge processes
take this path and abort. The panic happens on the `open` call itself, before
any success or failure is known, so it is not specific to a missing endpoint.

Both probes now go through a small `probe_pipe` helper built on
`std::fs::OpenOptions`, the same approach `SyncStream::connect` already uses.
`ERROR_PIPE_BUSY` still maps to "a server is listening", so the busy-pipe
behaviour is unchanged.

Test: `sync_pipe_probes_do_not_panic_without_a_tokio_reactor` binds a real
listener inside a `#[tokio::test]` runtime, then probes it from a `std::thread`
that deliberately has no runtime. That split is the situation in the bug: the
server owns a runtime, the synchronous probes do not.

The test is load-bearing, verified rather than assumed. Two things had to be
got right for it to mean anything:

- the pipe must actually exist. An earlier version probed a path that was
  never bound, and it passed with and without this fix, because the open fails
  before tokio reaches the reactor registration;
- the listener must be bound under a runtime. An earlier version used a plain
  `#[test]`, which then panicked inside `Listener::bind` instead.

With `probe_pipe` reverted to `ClientOptions::open`, the spawned thread panics
with the message above and this test fails; the other six tests in the crate
pass in both states, so it isolates this defect.
@greptile-apps

greptile-apps Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Changes how Windows named pipes are probed for availability.

No outstanding findings block merging.

Summary

The Windows regression test now gives remove_socket a separately bound named pipe, addressing the previously reported coverage gap. No new issues were identified.

Reviews (2) · Last reviewed commit: "transport: probe remove_socket on its ow..."

Comment thread crates/jcode-transport/src/windows.rs Outdated
@greptile-apps

This comment has been minimized.

Reported as P2 on 1jehuang#1525: the regression test called `is_socket_path` and then
`remove_socket` on the same single-instance listener. Probing consumes the only
available instance of a Windows named pipe, so the second open can return
ERROR_PIPE_BUSY and return an error without ever reaching the code under test.
A reverted `ClientOptions::open` in `remove_socket` could therefore have slipped
through while the test still passed.

The test now binds a second, separate pipe for `remove_socket`, so each probe
hits a freshly available instance and each is independently covered.

Load-bearing proof repeated after the change, since the previous proof was for
the previous version of the test: with `probe_pipe` reverted to
`ClientOptions::open`, `sync_pipe_probes_do_not_panic_without_a_tokio_reactor`
is the only one of the seven that fails, and it fails with the reactor panic in
the spawned thread.
@gdamprint-cmyk

Copy link
Copy Markdown
Author

Correct, and the test was weaker than it claimed.

is_socket_path and remove_socket were probing the same single-instance
listener. Probing consumes the only available instance of a Windows named pipe,
so the second open can return ERROR_PIPE_BUSY and return an error without ever
reaching the code under test. A reverted ClientOptions::open inside
remove_socket could therefore have passed while the test was green.

Fixed in 6c83e653b: the test now binds a second, separate pipe for
remove_socket, so each probe hits a freshly available instance and each is
covered independently.

The load-bearing proof is repeated after the change rather than reused, because
the earlier proof was for the earlier version of the test. With probe_pipe
reverted to ClientOptions::open,
sync_pipe_probes_do_not_panic_without_a_tokio_reactor is the only one of the
seven that fails, and it fails with

there is no reactor running, must be called from the context of a Tokio 1.x runtime

in the spawned thread, not in the setup. With the fix in place all seven pass
and cargo fmt --check is clean.

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.

Windows: is_socket_path and remove_socket panic with 'there is no reactor running' when called from a plain thread

1 participant