From 5ae766d29c07ee3138aca10d4f2c1c1ee13278fb Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Mon, 1 Jun 2026 19:08:09 +0200 Subject: [PATCH 1/3] fix(sandbox): make interactive connect resilient on stopped/resumed sandboxes `sandbox connect` could hang on "Waiting for connection..." or fail when run against a stopped/resumed sandbox. Three independent issues: - The CLI swallowed real `attach()` failures: once the connection handshake landed, the same abort signal used to stop the premature-exit check also discarded any later `attach()` error, so failures were never surfaced. - The spinner's disposer called `ora.clear()` instead of `stop()`, leaving the render interval running and keeping the event loop (and the CLI) alive indefinitely on teardown. - When the interactive server exited early, the generic error hid the actual cause; we now include the server's stderr. - The in-sandbox server (pty-tunnel-server) trusted a leftover /tmp/vercel/interactive/config.json restored from a snapshot whenever its recorded PID happened to be alive, connecting to a dead socket. It now health-checks a reused server and removes the stale config before spawning a fresh one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-interactive-connect-resume.md | 5 +++ packages/pty-tunnel-server/modes/remote.go | 45 ++++++++++++++++++- .../interactive-shell/interactive-shell.ts | 39 +++++++++++----- 3 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-interactive-connect-resume.md diff --git a/.changeset/fix-interactive-connect-resume.md b/.changeset/fix-interactive-connect-resume.md new file mode 100644 index 00000000..937e8dda --- /dev/null +++ b/.changeset/fix-interactive-connect-resume.md @@ -0,0 +1,5 @@ +--- +"sandbox": patch +--- + +Fix `sandbox connect` hanging or failing on a stopped/resumed sandbox. The interactive shell now surfaces `attach()` failures instead of swallowing them once the connection handshake lands, always stops the spinner on teardown (so a failure can no longer hang the process), and includes the in-sandbox server's stderr when the interactive server exits early. The in-sandbox `vc-interactive-server` also health-checks a reused server before trusting a leftover config file, so a stale `/tmp/vercel/interactive/config.json` restored from a snapshot no longer causes it to connect to a dead socket. diff --git a/packages/pty-tunnel-server/modes/remote.go b/packages/pty-tunnel-server/modes/remote.go index d4ac81b4..43d471ea 100644 --- a/packages/pty-tunnel-server/modes/remote.go +++ b/packages/pty-tunnel-server/modes/remote.go @@ -33,10 +33,46 @@ var _ Bootstrapper = (*ExternalProcessBootstrapper)(nil) // GetOrCreateServer implements Bootstrapper. func (e *ExternalProcessBootstrapper) GetOrCreateServer() (info config.ServerInfo, err error) { info, err = config.VerifyConnection(e.ConfigPath) + if err == nil { + // A live PID is not sufficient evidence that the server is usable. Across + // a snapshot/resume the config file is restored from the snapshot while + // the original server process is gone: the recorded PID may have been + // reused by an unrelated process, or a memory-restored daemon may no + // longer be serving. + if healthErr := e.pingServer(info.Port); healthErr == nil { + return info, nil + } else { + e.Logger.Info( + "Existing server config is stale (failed health check), spawning a new server", + "port", info.Port, + "pid", info.PID, + "error", healthErr, + ) + } + } + return e.spawnServer() +} + +func (e *ExternalProcessBootstrapper) pingServer(port int) error { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + url := fmt.Sprintf("http://localhost:%d/health", port) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return e.spawnServer() + return err } - return + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", res.StatusCode) + } + return nil } func (e *ExternalProcessBootstrapper) spawnServer() (info config.ServerInfo, err error) { @@ -66,6 +102,11 @@ func (e *ExternalProcessBootstrapper) spawnServer() (info config.ServerInfo, err basename := path.Join(os.TempDir(), fmt.Sprintf("pty-tunnel-server-%d", time.Now().Nanosecond())) e.Logger.Debug("Creating temporary files for server stdout/stderr", "basename", basename) + // Remove any leftover config before starting the new server. + if rmErr := os.Remove(e.ConfigPath); rmErr != nil && !os.IsNotExist(rmErr) { + e.Logger.Debug("Could not remove stale server config", "path", e.ConfigPath, "error", rmErr) + } + e.Logger.Info("Spawning new pty-tunnel-server process", "args", cmd.Args) cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, // new process group diff --git a/packages/sandbox/src/interactive-shell/interactive-shell.ts b/packages/sandbox/src/interactive-shell/interactive-shell.ts index 3e90e02c..212a6e56 100644 --- a/packages/sandbox/src/interactive-shell/interactive-shell.ts +++ b/packages/sandbox/src/interactive-shell/interactive-shell.ts @@ -193,7 +193,7 @@ export async function startInteractiveShell(options: { using progress = acquireRelease( () => ora({ discardStdin: false }).start(), - (s) => s.clear(), + (s) => s.stop(), ); progress.text = "Setting up sandbox environment"; @@ -226,7 +226,12 @@ export async function startInteractiveShell(options: { }); await Promise.all([ - throwIfCommandPrematurelyExited(command, waitForProcess.signal), + // `throwIfCommandPrematurelyExited` rejects with an abort error once the + // connection is established; swallow only that interruption (a genuine + // premature exit is thrown before the abort and still propagates). + throwIfCommandPrematurelyExited(command, waitForProcess.signal).catch( + waitForProcess.ignoreInterruptions, + ), attach({ sandbox: options.sandbox, progress, @@ -237,28 +242,40 @@ export async function startInteractiveShell(options: { printCommand(options.execution[0], options.execution.slice(1)), ), }), - ]).catch(waitForProcess.ignoreInterruptions); + ]); } async function throwIfCommandPrematurelyExited( command: Command, signal: AbortSignal, ) { + let exitCode: number; try { - const { exitCode } = await command.wait({ signal }); - throw new Error( - [ - `Interactive shell failed to start (exit code: ${exitCode}).`, - `${chalk.bold("hint:")} The sandbox may have timed out or encountered an error.`, - "╰▶ Check sandbox status with `sandbox list` or view logs for details.", - ].join("\n"), - ); + ({ exitCode } = await command.wait({ signal })); } catch (err) { if (signal.aborted) { return; } throw err; } + + // The interactive server process exited before a connection was established. + // Surface its stderr. + let serverError = ""; + try { + serverError = (await command.stderr({ signal })).trim(); + } catch { + // Best-effort: never let reading the failure output mask the real error. + } + + throw new Error( + [ + `Interactive shell failed to start (exit code: ${exitCode}).`, + `${chalk.bold("hint:")} The sandbox may have timed out or encountered an error.`, + ...(serverError ? [chalk.dim(serverError)] : []), + "╰▶ Check sandbox status with `sandbox list` or view logs for details.", + ].join("\n"), + ); } async function attach({ From b3d0f2e3e0fb61c3eb82d6a09592dc20cfd89287 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Tue, 2 Jun 2026 17:17:15 +0200 Subject: [PATCH 2/3] small fix --- packages/vercel-sandbox/src/sandbox.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index bb232f03..86c6e549 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -938,6 +938,10 @@ export class Sandbox { resume: true, signal, }); + // Do not update the session if it was not resumed. + if (!response.json.resumed && this.session) { + return; + } this.session = new Session({ client, routes: response.json.routes, From e97ed8a304109a85bc681b28a8792c0430e7cd61 Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Tue, 2 Jun 2026 20:40:30 +0200 Subject: [PATCH 3/3] always resume when interactive --- packages/sandbox/src/commands/exec.ts | 3 +++ packages/vercel-sandbox/src/sandbox.ts | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/sandbox/src/commands/exec.ts b/packages/sandbox/src/commands/exec.ts index e9b83bd7..85aa4bca 100644 --- a/packages/sandbox/src/commands/exec.ts +++ b/packages/sandbox/src/commands/exec.ts @@ -114,6 +114,9 @@ export const exec = cmd.command({ projectId: project, teamId: team, token, + // Resume up front so the sandbox is already running by the time the + // interactive-shell setup runs its parallel steps. + resume: true, __includeSystemRoutes: true, }); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 86c6e549..bb232f03 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -938,10 +938,6 @@ export class Sandbox { resume: true, signal, }); - // Do not update the session if it was not resumed. - if (!response.json.resumed && this.session) { - return; - } this.session = new Session({ client, routes: response.json.routes,