Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-interactive-connect-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sandbox": patch
Comment thread
vercel[bot] marked this conversation as resolved.
---

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.
45 changes: 43 additions & 2 deletions packages/pty-tunnel-server/modes/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions packages/sandbox/src/interactive-shell/interactive-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions packages/vercel-sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
marc-vercel marked this conversation as resolved.
Outdated
return;
}
this.session = new Session({
client,
routes: response.json.routes,
Expand Down
Loading