diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index f5f83b77b..7ad960a7f 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2305,15 +2305,27 @@ async function createSandbox( run(`rm -rf "${buildCtx}"`, { ignoreError: true }); if (createResult.status !== 0) { - console.error(""); - console.error(` Sandbox creation failed (exit ${createResult.status}).`); - if (createResult.output) { + const failure = classifySandboxCreateFailure(createResult.output); + if (failure.kind === "sandbox_create_incomplete") { + // The sandbox was created in the gateway but the create stream exited + // with a non-zero code (e.g. SSH 255). Fall through to the ready-wait + // loop — the sandbox may still reach Ready on its own. + console.warn(""); + console.warn( + ` Create stream exited with code ${createResult.status} after sandbox was created.`, + ); + console.warn(" Checking whether the sandbox reaches Ready state..."); + } else { console.error(""); - console.error(createResult.output); + console.error(` Sandbox creation failed (exit ${createResult.status}).`); + if (createResult.output) { + console.error(""); + console.error(createResult.output); + } + console.error(" Try: openshell sandbox list # check gateway state"); + printSandboxCreateRecoveryHints(createResult.output); + process.exit(createResult.status || 1); } - console.error(" Try: openshell sandbox list # check gateway state"); - printSandboxCreateRecoveryHints(createResult.output); - process.exit(createResult.status || 1); } // Wait for sandbox to reach Ready state in k3s before registering. diff --git a/src/lib/sandbox-create-stream.test.ts b/src/lib/sandbox-create-stream.test.ts index 58b74bff4..1be39b876 100644 --- a/src/lib/sandbox-create-stream.test.ts +++ b/src/lib/sandbox-create-stream.test.ts @@ -109,6 +109,50 @@ describe("sandbox-create-stream", () => { }); }); + it("recovers when sandbox is ready at the moment the stream exits non-zero", async () => { + const child = new FakeChild(); + const logLine = vi.fn(); + const promise = streamSandboxCreate("echo create", process.env, { + spawnImpl: () => child as never, + readyCheck: () => true, // sandbox is already Ready + pollIntervalMs: 60_000, // large interval so the poll doesn't fire first + heartbeatIntervalMs: 1_000, + silentPhaseMs: 10_000, + logLine, + }); + + child.stdout.emit("data", Buffer.from("Created sandbox: demo\n")); + // SSH 255 — stream exits non-zero after sandbox was created + child.emit("close", 255); + + await expect(promise).resolves.toMatchObject({ + status: 0, + forcedReady: true, + sawProgress: true, + }); + }); + + it("returns non-zero when readyCheck is false at close time", async () => { + const child = new FakeChild(); + const promise = streamSandboxCreate("echo create", process.env, { + spawnImpl: () => child as never, + readyCheck: () => false, // sandbox is NOT ready + pollIntervalMs: 60_000, + heartbeatIntervalMs: 1_000, + silentPhaseMs: 10_000, + logLine: vi.fn(), + }); + + child.stdout.emit("data", Buffer.from("Created sandbox: demo\n")); + child.emit("close", 255); + + await expect(promise).resolves.toMatchObject({ + status: 255, + sawProgress: true, + }); + expect((await promise).forcedReady).toBeUndefined(); + }); + it("reports spawn errors cleanly", async () => { const child = new FakeChild(); const promise = streamSandboxCreate("echo create", process.env, { diff --git a/src/lib/sandbox-create-stream.ts b/src/lib/sandbox-create-stream.ts index 9b3e4a73e..443f5f7b2 100644 --- a/src/lib/sandbox-create-stream.ts +++ b/src/lib/sandbox-create-stream.ts @@ -250,6 +250,18 @@ export function streamSandboxCreate( }); child.on("close", (code) => { + // One last ready-check: the sandbox may have become Ready between the + // last poll tick and the stream exit (e.g. SSH 255 after "Created sandbox:"). + if (code && code !== 0 && options.readyCheck) { + try { + if (options.readyCheck()) { + finish(0, { forcedReady: true }); + return; + } + } catch { + // Ignore — fall through to normal exit handling. + } + } finish(code ?? 1); }); });