Repository navigation
Dev tunnel: "Dev tunnel name" field is disconnected from real tunnels (identified by labels, not SDK name); connect lookup silently fails; endpoint URL uses wrong host token #1292
Description
Summary
The Settings → Remote access → "Dev tunnel name" field (and the identical wizard field from #1291) is disconnected from how real Phantom.Workspaces dev tunnels are actually identified, so LookupByNameAsync at connect time silently fails to find tunnels that are visibly hosted and healthy.
Concretely, the owner set "Dev tunnel name" = daemon-2 (screenshot confirms), but devtunnel list --json shows that no real tunnel has a name matching that — in fact every real Phantom.Workspaces tunnel has "name": "" (empty), and each is only distinguished by labels:
[
{
"tunnelId": "jolly-fog-cvqm81w",
"clusterId": "usw2",
"name": "",
"labels": ["phantom-workspaces", "phantom-workspaces-playspace"],
"hostConnections": 1,
"ports": [{ "portNumber": 5280, "portUri": "https://lgk3svvr-5280.usw2.devtunnels.ms/" }]
},
{
"tunnelId": "kind-hill-r100vm2",
"clusterId": "usw2",
"name": "",
"labels": ["phantom-workspaces", "phantom-workspaces-playspace3"],
"hostConnections": 0,
"ports": [{ "portNumber": 5282, "portUri": "https://n3vqgv0q-5282.usw2.devtunnels.ms/" }]
},
{
"tunnelId": "neat-lake-5ccvmt6",
"clusterId": "usw2",
"name": "",
"labels": ["daemon", "protocolv5", "vscode-server-launcher", "_flag3"],
"hostConnections": 1
}
]
The live jolly-fog-cvqm81w tunnel carries a STALE second label (phantom-workspaces-playspace) that was written under a previous TunnelName value. EnsureTunnelAsync reuses that tunnel by persisted TunnelId without changing its labels, so LookupByNameAsync("daemon-2") matches zero tunnels even though jolly-fog-cvqm81w is the tunnel the user believes they're using.
Two defects are covered here:
- Primary — Name/label mismatch, stale-label failure. The UI says "Dev tunnel name" and connect calls
LookupByNameAsync(TunnelName), but tunnels are not identified byTunnel.Name(deliberately left empty because "allow custom tunnel names" 403s on most accounts —DevTunnelManagementClientWrapper.cs:61-64). Instead they're identified by a label whose value is the rawTunnelNamestring. So when the user changesTunnelName, the persistedTunnelIdcontinues to reuse the same tunnel — whose second label is now stale — and connect fails silently. - Secondary — Endpoint URL host-token construction is wrong (CONFIRMED BUG).
DevTunnelEndpointResolver.cs:43buildshttps://{lookup.TunnelId}-{port}.{lookup.ClusterId}.devtunnels.ms/from the friendly tunnelId (e.g.jolly-fog-cvqm81w), producinghttps://jolly-fog-cvqm81w-5280.usw2.devtunnels.ms/. But the Management API's actualportUrifor the same port ishttps://lgk3svvr-5280.usw2.devtunnels.ms/— the host token (lgk3svvr) is not the friendly tunnel id. The correct URL is exposed viaTunnel.Endpoints[*].PortUriFormat(withTunnelEndpoint.PortTokenreplaced) orTunnelPort.PortForwardingUris[], and the wrapper's ownGetAccessPointUrlAsyncalready uses these correctly (DevTunnelManagementClientWrapper.cs:197-228) — the endpoint resolver duplicates that logic and gets it wrong.
Root Cause
Host-time labeling (what actually identifies tunnels)
DevTunnelHostService.cs:37-39 hands configuration.TunnelName to the wrapper:
this.descriptor = await this.managementClient
.EnsureTunnelAsync(configuration.TunnelId, configuration.TunnelName, cancellationToken)
.ConfigureAwait(false);EnsureTunnelAsync (DevTunnelManagementClientWrapper.cs:56-103) currently:
- Lists workspaces-marker tunnels (
ListWorkspacesTunnelsAsync(...)at line 68). - Reuses by
TunnelIdif the persisted id matches (lines 72–75). - Otherwise reuses by label if
nameLabelmatches (lines 77–80:tunnel = markerTunnels.FirstOrDefault(candidate => HasLabel(candidate, nameLabel))). - Otherwise auto-single (lines 81–90), where
markerTunnels.Count > 1throws. - Otherwise falls through to
CreateTunnelAsync(new Tunnel { Labels = BuildLabels(nameLabel) }, ...)(lines 92–97).
BuildLabels (:286-289):
private static string[] BuildLabels(string? nameLabel)
=> string.IsNullOrWhiteSpace(nameLabel)
? [DevTunnelNaming.WorkspacesMarkerLabel]
: [DevTunnelNaming.WorkspacesMarkerLabel, nameLabel];where WorkspacesMarkerLabel = "phantom-workspaces". Labels are only written on the create path (line 94); the reuse-by-TunnelId path never touches labels, so a stale second label survives across TunnelName edits. Tunnel.Name is asserted null by test DevTunnelManagementClientWrapperTests.cs:42 (Assert.Null(createdTunnel!.Name);).
Connect-time lookup
DevTunnelEndpointResolver.cs:28-33 routes on DevTunnelNaming.IsAuto:
var lookup = isAuto
? await this.lookupClient.DiscoverSingleAsync(cancellationToken).ConfigureAwait(false)
: await this.lookupClient.LookupByNameAsync(tunnelName, cancellationToken).ConfigureAwait(false);LookupByNameAsync (DevTunnelManagementClientWrapper.cs:230-240) matches on the label, not Tunnel.Name:
var tunnel = markerTunnels.FirstOrDefault(candidate => HasLabel(candidate, tunnelName))
?? throw new InvalidOperationException($"Dev tunnel '{tunnelName}' was not found.");with HasLabel (:291-294) doing an ordinal compare against Tunnel.Labels. DiscoverSingleAsync (:242-253) matches on the marker label only and throws "Multiple Workspaces dev tunnels were found; set a specific dev tunnel name instead of \"auto\"." when markerTunnels.Count > 1 — the message does not enumerate the candidates.
So host and connect agree on the identifier scheme (phantom-workspaces + raw TunnelName label), but the UI wording is misleading, the field is invisible in devtunnel list output, and — critically — reusing a tunnel by TunnelId allows the second label to drift out of sync with the field, causing silent connect failure.
Endpoint URL construction (secondary — CONFIRMED BUG)
DevTunnelEndpointResolver.cs:43:
var baseUri = new Uri($"https://{lookup.TunnelId}-{port}.{lookup.ClusterId}.devtunnels.ms/");DevTunnelLookupResult.TunnelId is the friendly tunnel id (e.g. jolly-fog-cvqm81w). The real per-port URI uses a different host token — devtunnel show for jolly-fog-cvqm81w+5280 returns https://lgk3svvr-5280.usw2.devtunnels.ms/. The Management API exposes the correct URLs via Tunnel.Endpoints[0].PortUriFormat (with TunnelEndpoint.PortToken replaced) and TunnelPort.PortForwardingUris, and GetAccessPointUrlAsync (:197-228) already consumes them correctly.
Design / Fix
Two independent fixes.
Fix 1 — Select or create tunnels by label; NEVER relabel an existing tunnel
Owner constraint (governing): "We should not ever replace labels on tunnels; users should be able to create multiple tunnels if that's what they really want to do." This overrides any reconcile/relabel approach.
Therefore the fix is driven entirely by matching the configured label; existing tunnels' labels are never mutated. A user who changes the configured name simply gets a new tunnel, and may end up with multiple phantom-workspaces-labelled tunnels, which is acceptable.
- Host — match by label, else create. In
EnsureTunnelAsync(DevTunnelManagementClientWrapper.cs:56-103), change the reuse policy so the configured label is the primary key:- If any tunnel in
markerTunnelshas bothphantom-workspacesand the configurednameLabel, host on it. (This is the existing label-match branch at lines 77–80 — keep it, but promote it above theTunnelIdbranch so a stale-TunnelIdsituation doesn't win against a fresh label match.) - If none matches, fall through to
CreateTunnelAsync(new Tunnel { Labels = BuildLabels(nameLabel) }, ...)(the existing create path at lines 92–97) — i.e. create a NEW tunnel with the configured labels. - Do not call
ManagementClient.UpdateTunnelAsyncon any existing tunnel to rewrite its labels.BuildLabelsremains write-once at create time. - The persisted
TunnelIdbecomes a hint for the create path (still stored on the descriptor), not an override that forces reuse of a mislabelled tunnel. In particular, theautopath (emptynameLabel) continues to behave as today: match any singlephantom-workspacestunnel.
- If any tunnel in
- Connect — look up strictly by label.
LookupByNameAsync(:230-240) already matches by label; no change to the matcher. No fallback/reconciliation is performed against a staleTunnelId. automulti-candidate — actionable error, no relabeling.DiscoverSingleAsync(:242-253) should keep throwing onmarkerTunnels.Count > 1(never relabel), but the message must enumerate the candidates — for eachphantom-workspaces-labelled tunnel, listTunnelId+ the second label (or(no label)) + port count — so the user can copy the label they want into the field. The helper text already tells users to useautoonly when there is a single Workspaces tunnel; the error now reinforces that with concrete choices.- UI clarification (wizard + Settings, shared). The field is a label, not an SDK tunnel name. Either:
- Rename to "Dev tunnel label" in the helper text/label surface (
RemoteAccessSettingsViewModel.TunnelNameHelperText), or - Add a read-only "Current tunnel" display showing the resolved
TunnelIdand the second label so the user can reconcile againstdevtunnel list --labels.
Because the setup-wizardDevTunnelWebfield and Settings → Remote access both bind throughRemoteAccessSettingsViewModel, apply the change once in the shared VM so both surfaces stay consistent. Coordinate with Setup wizard: DevTunnelWeb should require dev tunnel name (default auto), not an endpoint URL (endpoint is autodiscovered) #1291 — do not duplicate its wizard rework.
- Rename to "Dev tunnel label" in the helper text/label surface (
Fix 2 — Use the Management API's port URI instead of building a wrong one
In DevTunnelEndpointResolver.ResolveAsync, replace the string-interpolated URL at DevTunnelEndpointResolver.cs:43 with the API-supplied URL by threading PortUriFormat / PortForwardingUris through DevTunnelLookupResult. Sketch:
// DevTunnelLookupResult.cs
public sealed record DevTunnelLookupResult(
string TunnelId,
string ClusterId,
IReadOnlyList<int> ForwardedPorts,
string? PortUriFormat, // NEW: Tunnel.Endpoints[0].PortUriFormat
IReadOnlyDictionary<int, string> PortForwardingUris, // NEW: Tunnel.Ports[*].PortForwardingUris[0]
string? ConnectToken);
// DevTunnelManagementClientWrapper.ToLookupResult(...)
// populate the two new fields from the tunnel returned by the Management API.
// DevTunnelEndpointResolver.cs:43 (rewritten)
var port = lookup.ForwardedPorts[0];
var baseUri = ResolvePortUri(lookup, port)
?? throw new InvalidOperationException(
$"Dev tunnel '{lookup.TunnelId}' port {port} did not expose a public access point.");
static Uri? ResolvePortUri(DevTunnelLookupResult lookup, int port)
{
if (lookup.PortUriFormat is { Length: > 0 } format)
{
return new Uri(format.Replace(TunnelEndpoint.PortToken,
port.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal));
}
return lookup.PortForwardingUris.TryGetValue(port, out var url) ? new Uri(url) : null;
}This eliminates the wrong {friendlyTunnelId}-{port} host token and matches what GetAccessPointUrlAsync already does correctly.
Considered / Background — REJECTED: reconcile/replace labels on the existing tunnel
An earlier version of this bug proposed "reconcile on host: when the persisted TunnelId resolves to a tunnel whose second label differs from the current TunnelName, call ManagementClient.UpdateTunnelAsync to rewrite the labels to ["phantom-workspaces", <TunnelName>]", and a symmetric "reconcile on connect" fallback that would treat a stale-labelled owned tunnel as a match. This is rejected per the owner constraint above: "We should not ever replace labels on tunnels; users should be able to create multiple tunnels if that's what they really want to do." Recorded here so future readers understand why the host path creates a new tunnel on label mismatch rather than mutating the existing one.
Expected Tests
Naming style Subject_Scenario_ExpectedOutcome, matching existing classes (DevTunnelManagementClientWrapperTests, DevTunnelHostServiceTests, DevTunnelEndpointResolverTests).
| Test class | Test | Scenario |
|---|---|---|
DevTunnelManagementClientWrapperTests |
EnsureTunnelAsync_WhenExistingTunnelMatchesConfiguredLabel_HostsOnItWithoutMutatingLabels |
Two phantom-workspaces tunnels exist, one labelled daemon-2; call with nameLabel = "daemon-2" → hosts on that tunnel, UpdateTunnelAsync is never called, no CreateTunnelAsync call. |
DevTunnelManagementClientWrapperTests |
EnsureTunnelAsync_WhenNoExistingTunnelMatchesConfiguredLabel_CreatesNewTunnelAndLeavesOthersUntouched |
Existing tunnels have phantom-workspaces-playspace etc.; call with nameLabel = "daemon-2" → CreateTunnelAsync invoked with ["phantom-workspaces", "daemon-2"]; existing tunnels receive zero UpdateTunnelAsync calls. |
DevTunnelManagementClientWrapperTests |
EnsureTunnelAsync_WhenPersistedTunnelIdIsStaleAndLabelMatchesAnotherTunnel_PrefersLabelMatch |
Persisted TunnelId points at a mislabelled tunnel; a different tunnel carries the configured label → host on the label match, no relabeling. |
DevTunnelManagementClientWrapperTests |
LookupByNameAsync_MatchesTunnelByLabel_NotByTunnelName |
Regression guard: Tunnel.Name is empty on all candidates; label match wins. |
DevTunnelManagementClientWrapperTests |
DiscoverSingleAsync_WhenMultipleWorkspacesTunnelsExist_ThrowsWithCandidateLabelsListed |
Two phantom-workspaces tunnels → thrown message enumerates each candidate's TunnelId + second label; no UpdateTunnelAsync invoked. |
DevTunnelEndpointResolverTests |
ResolveAsync_UsesPortUriFormatFromLookupResult_NotFriendlyTunnelId |
Lookup result has PortUriFormat = "https://lgk3svvr-{port}.usw2.devtunnels.ms/" → resolved URI host token is lgk3svvr-<port>, not {friendlyTunnelId}-<port>. |
DevTunnelEndpointResolverTests |
ResolveAsync_WhenPortUriFormatMissing_FallsBackToPortForwardingUris |
No PortUriFormat, PortForwardingUris has entry for port → resolver returns that URI. |
DevTunnelEndpointResolverTests |
ResolveAsync_WhenNoPortUriExposed_ThrowsMentioningTunnelAndPort |
Neither PortUriFormat nor PortForwardingUris populated → thrown message includes tunnel id + port. |
DevTunnelHostServiceTests |
StartAsync_WithConfiguredLabelMatchingExistingTunnel_HostsWithoutMutatingLabels |
End-to-end: host with TunnelName = "daemon-2" and an existing matching tunnel → EnsureTunnelAsync reuses it; no label mutation. |
DevTunnelHostServiceTests |
StartAsync_WithConfiguredLabelNotMatchingAnyTunnel_CreatesNewTunnel |
End-to-end: host with TunnelName = "daemon-2" and no matching tunnel → new tunnel created; pre-existing tunnels unchanged. |
RemoteAccessSettingsViewModelTests |
TunnelNameHelperText_MentionsLabelSemantics |
If helper text is renamed to "Dev tunnel label", asserts wizard and Settings share the updated string via RemoteAccessSettingsViewModel. |
Relationship to #1291
#1291 (closed) fixed the setup wizard's DevTunnelWeb sub-view to collect a tunnel name defaulting to auto and reused RemoteAccessSettingsViewModel.TunnelNameHelperText so wizard and Settings surface the same string. That fix assumed the "name" concept was sound — this bug shows the value is really a label. Any rename ("Dev tunnel label"), helper-text change, or "current tunnel" read-only display must be applied via the shared RemoteAccessSettingsViewModel so wizard and Settings stay consistent. Do not duplicate #1291's wizard rework.
Activity