You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 status — with 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.
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:
vararguments=$"tunnel --accept-server-license-terms --name {tunnelName}";IVsCodeTunnelChildProcesschild;try{child=this.processLauncher(cliPath,arguments);}catch(Exceptionex){this.logger.LogError(ex,"Failed to spawn `code tunnel`: {Message}",ex.Message);returnWorkspaceToolExecutionResult.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){varexitCode=SafeExitCode(child);varstderr=child.CapturedStandardError;returnWorkspaceToolExecutionResult.Failure($"`code tunnel` exited with code {exitCode}.\nStderr:\n{stderr}");}varstatusResult=awaitthis.RunCliAsync(cliPath,"tunnel status",environmentVariables:null,VsCodeCliReporting.LogOnly,context.CancellationToken).ConfigureAwait(false);// ← FIRST probe, no grace periodvarreportsRunning=statusResult.ExitCode==0&&statusResult.StandardOut.Contains("running",StringComparison.OrdinalIgnoreCase);if(!reportsRunning){child.Kill();returnnewWorkspaceToolExecutionResult{ResultContent=$"`code tunnel status` no longer reports the tunnel as running "+$"(exit {statusResult.ExitCode}).\n{statusResult.StandardOut}",};}awaitthis.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:
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.
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.
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.
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".awaitthis.initialStatusCheckDelayAsync(context.CancellationToken).ConfigureAwait(false);while(!context.CancellationToken.IsCancellationRequested){if(child.HasExited){ … }// still catches an immediate crashvarstatusResult=awaitthis.RunCliAsync(cliPath,"tunnel status", …);
…
}
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.
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.
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.
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.).
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.
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.
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).
Cancelling during the initial grace delay kills the child process and returns cleanly (parallels existing RunVsCodeTunnelTool_CancellationRequested_KillsChildAndReturns).
Summary
RunVsCodeTunnelToolraces the tunnel's own startup. After spawningcode tunnel …as a child process, the runner drops straight into its poll loop and immediately invokescode tunnel status— with 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 statusno 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.Delaybefore 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 ANDcode tunnel statusreports 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.csRight after spawning the
code tunnelchild (RunVsCodeTunnelTool.cs:86-99), the runner enterswhile (!context.CancellationToken.IsCancellationRequested)and — as the very first work inside the loop — invokescode tunnel status. There is noTask.Delaybefore this first probe. The 15-secondwaitBetweenPollsAsynconly 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:RunVsCodeTunnelTool.cs:103-136— poll loop, first probe fires immediately:RunVsCodeTunnelTool.cs:64-66— default cadence between probes is 15 s, but there is no analogous "initial delay" seam:RunVsCodeTunnelTool.cs:32— the ambient schedule frequency (#1207) is one minute:How "not running" is currently reported on the first check — the failure path at
RunVsCodeTunnelTool.cs:124-133kills the child and returns aWorkspaceToolExecutionResultwhoseResultContentis`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
Phantom.Workspaces/Tools/RunVsCodeTunnelTool.cscode tunnel statusprobe, plus a constructor seam for the delay.Phantom.Workspaces.Tests/RunVsCodeTunnelToolTests.csRunVsCodeTunnelToolTests; new tests for the grace-period behavior go here (matches existingRunVsCodeTunnelTool_…naming).Design / Fix
Add a configurable initial grace period that is awaited once, before the poll loop's first status probe.
Add a constructor parameter
Func<CancellationToken, Task>? initialStatusCheckDelayAsyncalongside the existingwaitBetweenPollsAsync, defaulting toct => Task.Delay(TimeSpan.FromSeconds(10), ct). Store it in a field.Insert the delay immediately after successful spawn and BEFORE entering the poll loop:
Placing the delay outside the loop preserves the Rewrite RunVsCodeTunnelTool: spawn code tunnel directly, 1-minute default schedule, conditional GitHub login #1207 conjunction:
child.HasExitedis 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 existingchild.HasExitedbranch at lines 105-111). Only the "status doesn't say running yet" false-positive is suppressed.Default value: 10 seconds. Rationale: empirically the
code tunnelprocess 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.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 everyDefaultScheduleFrequency(1 minute). A tunnel that is genuinely broken will be detected either bychild.HasExited(immediately) or by the very nextcode tunnel statusprobe after the grace period, so end-to-end detection latency for a real failure grows by at most one grace-period worth of time.Cancellation continues to work:
initialStatusCheckDelayAsyncreceivescontext.CancellationToken, so a cancel during warm-up throwsOperationCanceledExceptionand the existing cancellation path (RunVsCodeTunnelTool_CancellationRequested_KillsChildAndReturns,RunVsCodeTunnelToolTests.cs:354) still applies — thechild.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(classRunVsCodeTunnelToolTests), following the existingRunVsCodeTunnelTool_<Scenario>_<ExpectedOutcome>naming style (seeRunVsCodeTunnelTool_ChildAliveAndStatusRunning_BlocksUntilConditionBreaksatRunVsCodeTunnelToolTests.cs:167,RunVsCodeTunnelTool_StatusNonZeroExit_TreatedAsNotRunningat:245, etc.).RunVsCodeTunnelTool_WaitsGracePeriodBeforeFirstStatusCheckRunVsCodeTunnelToolTestscode tunnel statusinvocation is issued until the injected initial-delay delegate has completed.RunVsCodeTunnelTool_TunnelUpAfterGracePeriod_ReportsRunningRunVsCodeTunnelToolTestsRunVsCodeTunnelTool_StatusStillFailsAfterGracePeriod_ReturnsNotRunningRunVsCodeTunnelToolTestsRunVsCodeTunnelTool_ChildExitsDuringGracePeriod_ReturnsFailureWithCliOutputRunVsCodeTunnelToolTestsRunVsCodeTunnelTool_CancellationDuringGracePeriod_KillsChildAndReturnsRunVsCodeTunnelToolTestsRunVsCodeTunnelTool_CancellationRequested_KillsChildAndReturns).RunVsCodeTunnelTool_DefaultInitialGracePeriod_IsTenSecondsRunVsCodeTunnelToolTestsRunVsCodeTunnelTool_DefaultScheduleFrequency_IsOneMinuteat:418).Cross-references