diff --git a/src/lib/sandbox-destroy-action.ts b/src/lib/sandbox-destroy-action.ts index 4a24982737..39af24c07a 100644 --- a/src/lib/sandbox-destroy-action.ts +++ b/src/lib/sandbox-destroy-action.ts @@ -22,15 +22,13 @@ import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, } from "./sandbox-session-state"; -import { stripAnsi } from "./openshell"; +import { + getSandboxDeleteOutcome, + shouldCleanupGatewayAfterDestroy, + shouldStopHostServicesAfterDestroy, +} from "./sandbox-destroy-helpers"; import { G, R, YW } from "./terminal-style"; -type SpawnLikeResult = { - status: number | null; - stdout?: string; - stderr?: string; -}; - type DockerRmi = (tag: string, opts?: { ignoreError?: boolean }) => { status: number | null }; type RemoveSandboxImageDeps = { @@ -81,23 +79,6 @@ function hasNoLiveSandboxes(): boolean { return parseLiveSandboxNames(liveList.output).size === 0; } -function isMissingSandboxDeleteResult(output = ""): boolean { - return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( - stripAnsi(output), - ); -} - -export function getSandboxDeleteOutcome(deleteResult: SpawnLikeResult): { - output: string; - alreadyGone: boolean; -} { - const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); - return { - output, - alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteResult(output), - }; -} - function cleanupSandboxServices( sandboxName: string, { stopHostServices = false }: { stopHostServices?: boolean } = {}, @@ -248,10 +229,12 @@ export async function destroySandbox( process.exit(deleteResult.status || 1); } - const shouldStopHostServices = - (deleteResult.status === 0 || alreadyGone) && - registry.listSandboxes().sandboxes.length === 1 && - !!registry.getSandbox(sandboxName); + const deleteSucceededOrAlreadyGone = deleteResult.status === 0 || alreadyGone; + const shouldStopHostServices = shouldStopHostServicesAfterDestroy({ + deleteSucceededOrAlreadyGone, + registeredSandboxCount: registry.listSandboxes().sandboxes.length, + sandboxStillRegistered: !!registry.getSandbox(sandboxName), + }); cleanupSandboxServices(sandboxName, { stopHostServices: shouldStopHostServices }); const removed = removeSandboxRegistryEntry(sandboxName); @@ -263,10 +246,12 @@ export async function destroySandbox( }); } if ( - (deleteResult.status === 0 || alreadyGone) && - removed && - registry.listSandboxes().sandboxes.length === 0 && - hasNoLiveSandboxes() + shouldCleanupGatewayAfterDestroy({ + deleteSucceededOrAlreadyGone, + removedRegistryEntry: removed, + noRegisteredSandboxes: registry.listSandboxes().sandboxes.length === 0, + noLiveSandboxes: hasNoLiveSandboxes(), + }) ) { cleanupGatewayAfterLastSandbox(); } diff --git a/src/lib/sandbox-destroy-helpers.test.ts b/src/lib/sandbox-destroy-helpers.test.ts new file mode 100644 index 0000000000..b3c7577968 --- /dev/null +++ b/src/lib/sandbox-destroy-helpers.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + getSandboxDeleteOutcome, + isMissingSandboxDeleteOutput, + shouldCleanupGatewayAfterDestroy, + shouldStopHostServicesAfterDestroy, +} from "./sandbox-destroy-helpers"; + +describe("sandbox destroy helpers", () => { + it("detects missing sandbox delete output", () => { + expect(isMissingSandboxDeleteOutput("Error: sandbox alpha not found")).toBe(true); + expect(isMissingSandboxDeleteOutput("\u001b[31mNotFound\u001b[0m: missing")).toBe(true); + expect(isMissingSandboxDeleteOutput("permission denied")).toBe(false); + }); + + it("classifies delete outcomes", () => { + expect(getSandboxDeleteOutcome({ status: 1, stderr: "Error: sandbox alpha not found" })).toEqual({ + output: "Error: sandbox alpha not found", + alreadyGone: true, + }); + expect(getSandboxDeleteOutcome({ status: 1, stdout: "boom" })).toEqual({ + output: "boom", + alreadyGone: false, + }); + expect(getSandboxDeleteOutcome({ status: 0, stdout: "deleted" })).toEqual({ + output: "deleted", + alreadyGone: false, + }); + }); + + it("decides when host services should stop before final registry removal", () => { + expect( + shouldStopHostServicesAfterDestroy({ + deleteSucceededOrAlreadyGone: true, + registeredSandboxCount: 1, + sandboxStillRegistered: true, + }), + ).toBe(true); + expect( + shouldStopHostServicesAfterDestroy({ + deleteSucceededOrAlreadyGone: true, + registeredSandboxCount: 2, + sandboxStillRegistered: true, + }), + ).toBe(false); + }); + + it("decides when gateway cleanup should run after destroy", () => { + expect( + shouldCleanupGatewayAfterDestroy({ + deleteSucceededOrAlreadyGone: true, + removedRegistryEntry: true, + noRegisteredSandboxes: true, + noLiveSandboxes: true, + }), + ).toBe(true); + expect( + shouldCleanupGatewayAfterDestroy({ + deleteSucceededOrAlreadyGone: true, + removedRegistryEntry: true, + noRegisteredSandboxes: true, + noLiveSandboxes: false, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/sandbox-destroy-helpers.ts b/src/lib/sandbox-destroy-helpers.ts new file mode 100644 index 0000000000..64c5aea75d --- /dev/null +++ b/src/lib/sandbox-destroy-helpers.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- pure helper tests exercise this module; orchestration coverage still runs through dist. */ + +import { stripAnsi } from "./openshell"; + +export type SpawnLikeResult = { + status: number | null; + stdout?: string; + stderr?: string; +}; + +export function isMissingSandboxDeleteOutput(output = ""): boolean { + return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( + stripAnsi(output), + ); +} + +export function getSandboxDeleteOutcome(deleteResult: SpawnLikeResult): { + output: string; + alreadyGone: boolean; +} { + const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); + return { + output, + alreadyGone: deleteResult.status !== 0 && isMissingSandboxDeleteOutput(output), + }; +} + +export function shouldStopHostServicesAfterDestroy(input: { + deleteSucceededOrAlreadyGone: boolean; + registeredSandboxCount: number; + sandboxStillRegistered: boolean; +}): boolean { + return ( + input.deleteSucceededOrAlreadyGone && + input.registeredSandboxCount === 1 && + input.sandboxStillRegistered + ); +} + +export function shouldCleanupGatewayAfterDestroy(input: { + deleteSucceededOrAlreadyGone: boolean; + removedRegistryEntry: boolean; + noRegisteredSandboxes: boolean; + noLiveSandboxes: boolean; +}): boolean { + return ( + input.deleteSucceededOrAlreadyGone && + input.removedRegistryEntry && + input.noRegisteredSandboxes && + input.noLiveSandboxes + ); +} + +/* v8 ignore stop */ diff --git a/src/lib/sandbox-rebuild-action.ts b/src/lib/sandbox-rebuild-action.ts index 9baaab7e06..56ae7fef3a 100644 --- a/src/lib/sandbox-rebuild-action.ts +++ b/src/lib/sandbox-rebuild-action.ts @@ -26,7 +26,8 @@ import * as policies from "./policies"; import * as registry from "./registry"; import { resolveOpenshell } from "./resolve-openshell"; import { parseLiveSandboxNames } from "./runtime-recovery"; -import { getSandboxDeleteOutcome, removeSandboxRegistryEntry } from "./sandbox-destroy-action"; +import { getSandboxDeleteOutcome } from "./sandbox-destroy-helpers"; +import { removeSandboxRegistryEntry } from "./sandbox-destroy-action"; import { executeSandboxCommand } from "./sandbox-process-recovery-action"; import { createSystemDeps as createSessionDeps, diff --git a/test/image-cleanup.test.ts b/test/image-cleanup.test.ts index 1b939c9d64..e441b652b8 100644 --- a/test/image-cleanup.test.ts +++ b/test/image-cleanup.test.ts @@ -9,10 +9,10 @@ import fs from "node:fs"; import path from "node:path"; import { - getSandboxDeleteOutcome, removeSandboxImage, removeSandboxRegistryEntry, } from "../src/lib/sandbox-destroy-action"; +import { getSandboxDeleteOutcome } from "../src/lib/sandbox-destroy-helpers"; import { normalizeGarbageCollectImagesOptions } from "../src/lib/lifecycle-options"; import { help as renderRootHelp } from "../src/lib/root-help-action";