Skip to content

VS Code tunnel runner should wait a grace period before the first tunnel-status check #1240

Description

@JoshuaRowePhantom

Summary

RunVsCodeTunnelTool races the tunnel's own startup. After spawning code tunnel … as a child process, the runner drops straight into its poll loop and immediately invokes code tunnel statuswith no initial delay — before the just-launched tunnel has had time to authenticate, bind, and register as "running". When that first probe returns a non-zero exit code or output that doesn't contain "running", the runner kills the child and returns a spurious failure ("code tunnel status no longer reports the tunnel as running …"), even though the tunnel was in the middle of coming up successfully.

The fix is to insert a configurable initial grace period (a Task.Delay before the first status check) so the tunnel has a chance to reach a stable state before the first liveness probe. This is a refinement of the #1207 conjunction-liveness loop (child alive AND code tunnel status reports running, on a ~1-minute cadence); the grace period only shifts the FIRST probe, and a genuinely-failed tunnel is still detected on the next iteration.

Related: refines #1207 (runner rewrite / conjunction liveness); complements #1206 (consistent stdout/stderr/exit-code reporting on tunnel tools); part of the broader tunnel work in #1194 / #1201.

Root Cause

File: Phantom.Workspaces/Tools/RunVsCodeTunnelTool.cs

Right after spawning the code tunnel child (RunVsCodeTunnelTool.cs:86-99), the runner enters while (!context.CancellationToken.IsCancellationRequested) and — as the very first work inside the loop — invokes code tunnel status. There is no Task.Delay before this first probe. The 15-second waitBetweenPollsAsync only fires at the end of the loop body, so it gates the interval between probes 1↔2, not before probe 1.

RunVsCodeTunnelTool.cs:86-99 — spawn:

var arguments = $"tunnel --accept-server-license-terms --name {tunnelName}";
IVsCodeTunnelChildProcess child;
try
{
    child = this.processLauncher(cliPath, arguments);
}
catch (Exception ex)
{
    this.logger.LogError(ex, "Failed to spawn `code tunnel`: {Message}", ex.Message);
    return WorkspaceToolExecutionResult.Failure(
        $"Failed to spawn `code tunnel`: {ex.Message}");
}

RunVsCodeTunnelTool.cs:103-136 — poll loop, first probe fires immediately:

while (!context.CancellationToken.IsCancellationRequested)
{
    if (child.HasExited)
    {
        var exitCode = SafeExitCode(child);
        var stderr = child.CapturedStandardError;
        return WorkspaceToolExecutionResult.Failure(
            $"`code tunnel` exited with code {exitCode}.\nStderr:\n{stderr}");
    }

    var statusResult = await this.RunCliAsync(
        cliPath,
        "tunnel status",
        environmentVariables: null,
        VsCodeCliReporting.LogOnly,
        context.CancellationToken).ConfigureAwait(false);   // ← FIRST probe, no grace period

    var reportsRunning =
        statusResult.ExitCode == 0
        && statusResult.StandardOut.Contains("running", StringComparison.OrdinalIgnoreCase);

    if (!reportsRunning)
    {
        child.Kill();
        return new WorkspaceToolExecutionResult
        {
            ResultContent =
                $"`code tunnel status` no longer reports the tunnel as running "
                + $"(exit {statusResult.ExitCode}).\n{statusResult.StandardOut}",
        };
    }

    await this.waitBetweenPollsAsync(context.CancellationToken).ConfigureAwait(false); // ← only gates probe N↔N+1
}

RunVsCodeTunnelTool.cs:64-66 — default cadence between probes is 15 s, but there is no analogous "initial delay" seam:

this.waitBetweenPollsAsync = waitBetweenPollsAsync
    ?? (ct => Task.Delay(TimeSpan.FromSeconds(15), ct));

RunVsCodeTunnelTool.cs:32 — the ambient schedule frequency (#1207) is one minute:

public static TimeSpan DefaultScheduleFrequency { get; } = TimeSpan.FromMinutes(1);

How "not running" is currently reported on the first check — the failure path at RunVsCodeTunnelTool.cs:124-133 kills the child and returns a WorkspaceToolExecutionResult whose ResultContent is `code tunnel status` no longer reports the tunnel as running (exit {ExitCode}). {StandardOut}. Because this path fires on the FIRST iteration with no grace period, it is the concrete spurious "tunnel did not start" symptom users see.

Affected Files

File Role
Phantom.Workspaces/Tools/RunVsCodeTunnelTool.cs Runner; needs an initial grace-period delay before the first code tunnel status probe, plus a constructor seam for the delay.
Phantom.Workspaces.Tests/RunVsCodeTunnelToolTests.cs Existing test class RunVsCodeTunnelToolTests; new tests for the grace-period behavior go here (matches existing RunVsCodeTunnelTool_… naming).

Design / Fix

Add a configurable initial grace period that is awaited once, before the poll loop's first status probe.

  1. Add a constructor parameter Func<CancellationToken, Task>? initialStatusCheckDelayAsync alongside the existing waitBetweenPollsAsync, defaulting to ct => Task.Delay(TimeSpan.FromSeconds(10), ct). Store it in a field.

  2. Insert the delay immediately after successful spawn and BEFORE entering the poll loop:

// Give the freshly-spawned `code tunnel` a chance to authenticate and register
// before the first liveness probe, so the first `code tunnel status` call does
// not race startup and spuriously report "not running".
await this.initialStatusCheckDelayAsync(context.CancellationToken).ConfigureAwait(false);

while (!context.CancellationToken.IsCancellationRequested)
{
    if (child.HasExited) {}        // still catches an immediate crash
    var statusResult = await this.RunCliAsync(cliPath, "tunnel status",);}
  1. Placing the delay outside the loop preserves the Rewrite RunVsCodeTunnelTool: spawn code tunnel directly, 1-minute default schedule, conditional GitHub login #1207 conjunction: child.HasExited is still checked on every iteration (including the first, right after the delay), so a tunnel that crashes DURING the grace window is still caught (its exit code + stderr are reported by the existing child.HasExited branch at lines 105-111). Only the "status doesn't say running yet" false-positive is suppressed.

  2. Default value: 10 seconds. Rationale: empirically the code tunnel process typically prints its "Open this link…" URL and reaches the "connected" state within a few seconds of spawn under normal conditions; 10 s comfortably covers the common case while being small compared to both the 15 s inter-probe delay and the 1-minute (Rewrite RunVsCodeTunnelTool: spawn code tunnel directly, 1-minute default schedule, conditional GitHub login #1207) reschedule cadence. The value is a constructor delegate so tests can pass a no-op and production callers can override it (via DI configuration) if their environment needs a longer warm-up.

  3. Interaction with Rewrite RunVsCodeTunnelTool: spawn code tunnel directly, 1-minute default schedule, conditional GitHub login #1207 cadence. The grace period only shifts probe Bump actions/checkout from 4 to 7 #1. Subsequent probes still occur every waitBetweenPollsAsync (default 15 s), and the outer schedule still respawns dead tunnels every DefaultScheduleFrequency (1 minute). A tunnel that is genuinely broken will be detected either by child.HasExited (immediately) or by the very next code tunnel status probe after the grace period, so end-to-end detection latency for a real failure grows by at most one grace-period worth of time.

  4. Cancellation continues to work: initialStatusCheckDelayAsync receives context.CancellationToken, so a cancel during warm-up throws OperationCanceledException and the existing cancellation path (RunVsCodeTunnelTool_CancellationRequested_KillsChildAndReturns, RunVsCodeTunnelToolTests.cs:354) still applies — the child.Kill() cleanup path needs to also run if cancellation lands during the grace delay (wrap the delay + loop in a try/catch/finally that kills the child on cancellation, consistent with existing behavior).

Expected Tests

New tests go in Phantom.Workspaces.Tests/RunVsCodeTunnelToolTests.cs (class RunVsCodeTunnelToolTests), following the existing RunVsCodeTunnelTool_<Scenario>_<ExpectedOutcome> naming style (see RunVsCodeTunnelTool_ChildAliveAndStatusRunning_BlocksUntilConditionBreaks at RunVsCodeTunnelToolTests.cs:167, RunVsCodeTunnelTool_StatusNonZeroExit_TreatedAsNotRunning at :245, etc.).

Test Name Class What It Verifies
RunVsCodeTunnelTool_WaitsGracePeriodBeforeFirstStatusCheck RunVsCodeTunnelToolTests No code tunnel status invocation is issued until the injected initial-delay delegate has completed.
RunVsCodeTunnelTool_TunnelUpAfterGracePeriod_ReportsRunning RunVsCodeTunnelToolTests Given a fake CLI that fails the status check if called before T=grace but reports "running" afterwards, the tool blocks (does not return failure) — i.e. the grace period is actually honored.
RunVsCodeTunnelTool_StatusStillFailsAfterGracePeriod_ReturnsNotRunning RunVsCodeTunnelToolTests A tunnel that still doesn't report "running" AFTER the grace period is treated as failed (kills child, returns the existing "no longer reports the tunnel as running" result); grace period does not mask real failures.
RunVsCodeTunnelTool_ChildExitsDuringGracePeriod_ReturnsFailureWithCliOutput RunVsCodeTunnelToolTests If the spawned child exits during the initial grace delay, the tool still returns failure with the child's exit code + stderr on the first loop iteration (does not swallow crashes that occur during warm-up).
RunVsCodeTunnelTool_CancellationDuringGracePeriod_KillsChildAndReturns RunVsCodeTunnelToolTests Cancelling during the initial grace delay kills the child process and returns cleanly (parallels existing RunVsCodeTunnelTool_CancellationRequested_KillsChildAndReturns).
RunVsCodeTunnelTool_DefaultInitialGracePeriod_IsTenSeconds RunVsCodeTunnelToolTests Documents/pins the default grace-period value (parallels existing RunVsCodeTunnelTool_DefaultScheduleFrequency_IsOneMinute at :418).

Cross-references

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions