Skip to content

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

@JoshuaRowePhantom

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:

  1. Primary — Name/label mismatch, stale-label failure. The UI says "Dev tunnel name" and connect calls LookupByNameAsync(TunnelName), but tunnels are not identified by Tunnel.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 raw TunnelName string. So when the user changes TunnelName, the persisted TunnelId continues to reuse the same tunnel — whose second label is now stale — and connect fails silently.
  2. Secondary — Endpoint URL host-token construction is wrong (CONFIRMED BUG). DevTunnelEndpointResolver.cs:43 builds https://{lookup.TunnelId}-{port}.{lookup.ClusterId}.devtunnels.ms/ from the friendly tunnelId (e.g. jolly-fog-cvqm81w), producing https://jolly-fog-cvqm81w-5280.usw2.devtunnels.ms/. But the Management API's actual portUri for the same port is https://lgk3svvr-5280.usw2.devtunnels.ms/ — the host token (lgk3svvr) is not the friendly tunnel id. The correct URL is exposed via Tunnel.Endpoints[*].PortUriFormat (with TunnelEndpoint.PortToken replaced) or TunnelPort.PortForwardingUris[], and the wrapper's own GetAccessPointUrlAsync already 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 TunnelId if the persisted id matches (lines 72–75).
  • Otherwise reuses by label if nameLabel matches (lines 77–80: tunnel = markerTunnels.FirstOrDefault(candidate => HasLabel(candidate, nameLabel))).
  • Otherwise auto-single (lines 81–90), where markerTunnels.Count > 1 throws.
  • 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.

  1. 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 markerTunnels has both phantom-workspaces and the configured nameLabel, host on it. (This is the existing label-match branch at lines 77–80 — keep it, but promote it above the TunnelId branch so a stale-TunnelId situation 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.UpdateTunnelAsync on any existing tunnel to rewrite its labels. BuildLabels remains write-once at create time.
    • The persisted TunnelId becomes a hint for the create path (still stored on the descriptor), not an override that forces reuse of a mislabelled tunnel. In particular, the auto path (empty nameLabel) continues to behave as today: match any single phantom-workspaces tunnel.
  2. 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 stale TunnelId.
  3. auto multi-candidate — actionable error, no relabeling. DiscoverSingleAsync (:242-253) should keep throwing on markerTunnels.Count > 1 (never relabel), but the message must enumerate the candidates — for each phantom-workspaces-labelled tunnel, list TunnelId + 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 use auto only when there is a single Workspaces tunnel; the error now reinforces that with concrete choices.
  4. 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 TunnelId and the second label so the user can reconcile against devtunnel list --labels.
      Because the setup-wizard DevTunnelWeb field and Settings → Remote access both bind through RemoteAccessSettingsViewModel, 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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identified

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions