From 95fe54bbe0f56eeda79c87d1337baf79b8f5aeba Mon Sep 17 00:00:00 2001
From: Juan Pablo Acosta <90735251+jpablo2002@users.noreply.github.com>
Date: Wed, 2 Sep 2026 14:14:32 -0700
Subject: [PATCH 1/2] Send the host's process id to the relay on host
connections so it can tell a host reconnecting to its own tunnel from a
different host taking it over.
---
cs/src/Connections/TunnelRelayConnection.cs | 11 ++++++++++
.../Connections/TunnelRelayStreamFactory.cs | 10 +++++++++
.../defaultTunnelRelayStreamFactory.ts | 22 +++++++++++++++++++
3 files changed, 43 insertions(+)
diff --git a/cs/src/Connections/TunnelRelayConnection.cs b/cs/src/Connections/TunnelRelayConnection.cs
index cd005917..d09d3f1f 100644
--- a/cs/src/Connections/TunnelRelayConnection.cs
+++ b/cs/src/Connections/TunnelRelayConnection.cs
@@ -50,6 +50,17 @@ public abstract class TunnelRelayConnection : TunnelConnection, IRelayClient, IP
#endregion
+ ///
+ /// Request header that a host sends to the relay to identify its own process.
+ ///
+ ///
+ /// The value is , which stays the same for the
+ /// lifetime of the process. It lets the relay recognize a host that is reconnecting to a
+ /// tunnel it already holds, rather than treating it as a different host taking the tunnel
+ /// over. Clients do not send this header.
+ ///
+ public const string HostIdHeaderName = "X-Tunnels-Host-Process-Id";
+
///
/// Maximum retry delay, ms.
/// After the 6th attempt the delay will reach 2^7 * 100ms = 12.8s and stop doubling
diff --git a/cs/src/Connections/TunnelRelayStreamFactory.cs b/cs/src/Connections/TunnelRelayStreamFactory.cs
index 81785fef..32dc45ca 100644
--- a/cs/src/Connections/TunnelRelayStreamFactory.cs
+++ b/cs/src/Connections/TunnelRelayStreamFactory.cs
@@ -25,6 +25,10 @@ public class TunnelRelayStreamFactory : ITunnelRelayStreamFactory
TraceSource trace,
CancellationToken cancellation)
{
+ var isHostConnection =
+ Array.IndexOf(subprotocols, TunnelRelayConnection.HostWebSocketSubProtocol) >= 0 ||
+ Array.IndexOf(subprotocols, TunnelRelayConnection.HostWebSocketSubProtocolV2) >= 0;
+
void ConfigureWebSocketOptions(ClientWebSocketOptions options)
{
foreach (var subprotocol in subprotocols)
@@ -36,6 +40,12 @@ void ConfigureWebSocketOptions(ClientWebSocketOptions options)
{
options.SetRequestHeader("Authorization", "tunnel " + accessToken);
}
+
+ if (isHostConnection && !string.IsNullOrEmpty(MultiModeTunnelHost.HostId))
+ {
+ options.SetRequestHeader(
+ TunnelRelayConnection.HostIdHeaderName, MultiModeTunnelHost.HostId);
+ }
}
var stream = await WebSocketStream.ConnectToWebSocketAsync(
diff --git a/ts/src/connections/defaultTunnelRelayStreamFactory.ts b/ts/src/connections/defaultTunnelRelayStreamFactory.ts
index 1ba6b7ab..d2d64186 100644
--- a/ts/src/connections/defaultTunnelRelayStreamFactory.ts
+++ b/ts/src/connections/defaultTunnelRelayStreamFactory.ts
@@ -4,8 +4,23 @@
import { Stream } from '@microsoft/dev-tunnels-ssh';
import { TunnelRelayStreamFactory } from './tunnelRelayStreamFactory';
import { isNode, SshHelpers } from './sshHelpers';
+import { MultiModeTunnelHost } from './multiModeTunnelHost';
import { IClientConfig } from 'websocket';
+/**
+ * Request header that a host sends to the relay to identify its own process.
+ *
+ * The value is `MultiModeTunnelHost.hostId`, which stays the same for the lifetime of the
+ * process. It lets the relay recognize a host that is reconnecting to a tunnel it already
+ * holds, rather than treating it as a different host taking the tunnel over. Clients do not
+ * send this header.
+ */
+const hostIdHeaderName = 'X-Tunnels-Host-Process-Id';
+
+// Mirrors the host sub-protocols in tunnelRelayTunnelHost.ts. They are duplicated here rather
+// than imported because that module reaches back to this one through the connection session.
+const hostSubProtocols = ['tunnel-relay-host', 'tunnel-relay-host-v2-dev'];
+
/**
* Default factory for creating streams to a tunnel relay.
*/
@@ -17,17 +32,24 @@ export class DefaultTunnelRelayStreamFactory implements TunnelRelayStreamFactory
clientConfig?: IClientConfig,
): Promise<{ stream: Stream, protocol: string }> {
if (isNode()) {
+ const isHostConnection = protocols.some((p) => hostSubProtocols.includes(p));
const stream = await SshHelpers.openConnection(
relayUri,
protocols,
{
...(accessToken && { Authorization: `tunnel ${accessToken}` }),
+ ...(isHostConnection &&
+ MultiModeTunnelHost.hostId && {
+ [hostIdHeaderName]: MultiModeTunnelHost.hostId,
+ }),
},
clientConfig,
);
return { stream, protocol: stream.protocol! };
} else {
// Web sockets don't support auth. Authenticate TunnelRelay by sending accessToken as a subprotocol.
+ // Request headers aren't available here either, so a host running in the browser cannot
+ // identify its process to the relay.
if (accessToken) {
protocols = [...protocols, accessToken];
}
From 1f0caaa36fb2e2c3d09e033d82891a938c18690e Mon Sep 17 00:00:00 2001
From: Juan Pablo Acosta <90735251+jpablo2002@users.noreply.github.com>
Date: Thu, 3 Sep 2026 13:18:59 -0700
Subject: [PATCH 2/2] Send the host id header from the Rust relay host.
Matches the C# and TypeScript SDKs so the relay can identify a reconnecting host process.
---
rs/src/connections/relay_tunnel_host.rs | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/rs/src/connections/relay_tunnel_host.rs b/rs/src/connections/relay_tunnel_host.rs
index c3fddaa9..cee99623 100644
--- a/rs/src/connections/relay_tunnel_host.rs
+++ b/rs/src/connections/relay_tunnel_host.rs
@@ -49,6 +49,12 @@ type PortMap = HashMap>;
// large responses continue making progress.
const CHANNEL_WRITE_CHUNK_SIZE: usize = 32 * 1024;
+// Identifies the host process to the relay, so it can tell a host reconnecting
+// from a genuinely different host competing for the same tunnel. The value is
+// the same `host_id` reported on the TunnelEndpoint and stays constant for the
+// lifetime of a RelayTunnelHost. Only host connections send it; clients do not.
+const HOST_ID_HEADER_NAME: &str = "X-Tunnels-Host-Process-Id";
+
/// The RelayTunnelHost can host connections via the tunneling service. After
/// creating it, you will generally want to run `connect()` to create a new
/// a new connection.
@@ -427,12 +433,14 @@ impl RelayTunnelHost {
.as_deref()
.ok_or(TunnelError::MissingHostEndpoint)?;
+ let host_id = self.host_id.to_string();
let req = build_websocket_request(
url,
&[
("Sec-WebSocket-Protocol", "tunnel-relay-host"),
("Authorization", &format!("tunnel {}", host_token)),
("User-Agent", self.mgmt.user_agent.to_str().unwrap()),
+ (HOST_ID_HEADER_NAME, &host_id),
],
)?;