[GH-ISSUE #2559] fix(hopper): Linux installer cleanup errors replace completed install results #876

Closed
opened 2026-10-15 18:29:12 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 13, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/2559

Summary

When Linux Hopper installation reaches temporary-directory cleanup and that removal fails, the installer rejects instead of returning the completed installation result. This happens after a successful package installation with a ready launcher and after a package-manager failure that should have its own structured reason. The caller loses the installation outcome, and the production setup owner has no retained cleanup capability for the temporary directory. The failure was reproduced at the actual installer boundary with injected host effects; no real package installation or Linux filesystem failure was performed.

Steps to reproduce

  1. Use the tested source baseline and installed lockfile dependencies described under Runtime or environment. Save the following fixture as src/application/hopperCleanupOutcome.test.ts. It uses a supported Ubuntu 24.04 distribution, three download bytes with matching release length and SHA-1, and a ready launcher; it performs no network, filesystem, or package-manager operations.
  2. Run the focused test command below. The two cleanup-success controls pass. The two rejection-characterization checks pass, confirming that cleanup replaces either completed outcome. The two contract checks fail because they require the installation outcome to remain available despite failed cleanup.
import { createHash } from "node:crypto";
import { expect, it } from "vitest";

import {
  installLinuxHopper,
  type LinuxHopperInstallHost,
} from "./LinuxHopper.js";

function fixture(cleanupFails: boolean, installSucceeds = true) {
  const bytes = new Uint8Array([1, 2, 3]);
  const release = {
    filename: "https://example.invalid/Hopper.deb",
    file_length: String(bytes.byteLength),
    file_hash: createHash("sha1").update(bytes).digest("hex"),
  };
  const calls: string[] = [];
  const host: LinuxHopperInstallHost = {
    distribution: async () => ({
      id: "ubuntu",
      versionId: "24.04",
      packageFamily: "deb",
      supported: true,
    }),
    download: async () => ({ ok: true, bytes }),
    createTemporaryDirectory: async () => "owned-hopper-fixture",
    writeArchive: async () => undefined,
    installPackage: async () => {
      calls.push("installPackage");
      return installSucceeds;
    },
    launcherStatus: async () => {
      calls.push("launcherStatus:ready");
      return "ready";
    },
    cleanup: async (path) => {
      calls.push(`cleanup:${path}`);
      if (cleanupFails) throw new Error("temporary cleanup denied");
    },
  };
  return {
    calls,
    run: () => installLinuxHopper(host, { releases: { deb: release, rpm: release, arch: release } }),
  };
}

it("returns the successful install result when cleanup succeeds", async () => {
  const { calls, run } = fixture(false);
  await expect(run()).resolves.toEqual({ status: "installed", launcherPath: "/opt/hopper/bin/Hopper" });
  expect(calls).toEqual(["installPackage", "launcherStatus:ready", "cleanup:owned-hopper-fixture"]);
});

it("returns the package-manager failure when cleanup succeeds", async () => {
  const { calls, run } = fixture(false, false);
  await expect(run()).resolves.toEqual({ status: "failed", reason: "authorization_or_package_manager" });
  expect(calls).toEqual(["installPackage", "cleanup:owned-hopper-fixture"]);
});

it("observes cleanup replacing the successful install result", async () => {
  const { calls, run } = fixture(true);
  await expect(run()).rejects.toThrow("temporary cleanup denied");
  expect(calls).toEqual(["installPackage", "launcherStatus:ready", "cleanup:owned-hopper-fixture"]);
});

it("observes cleanup replacing the package-manager failure result", async () => {
  const { calls, run } = fixture(true, false);
  await expect(run()).rejects.toThrow("temporary cleanup denied");
  expect(calls).toEqual(["installPackage", "cleanup:owned-hopper-fixture"]);
});

it("preserves the successful install outcome despite cleanup failure", async () => {
  const { run } = fixture(true);
  await expect(run()).resolves.toMatchObject({ status: "installed", launcherPath: "/opt/hopper/bin/Hopper" });
});

it("preserves the package-manager failure despite cleanup failure", async () => {
  const { run } = fixture(true, false);
  await expect(run()).resolves.toMatchObject({ status: "failed", reason: "authorization_or_package_manager" });
});
npm run test:focused -- src/application/hopperCleanupOutcome.test.ts

Expected behavior

After the supported Linux installation attempt completes, the caller should still receive its installed status and launcher path, or its specific installation-failure reason, even if removing the owned temporary directory fails. Failed cleanup should remain visible, and the responsible owner should retain enough information or capability to retry removal safely. A successful cleanup should leave the normal installation result unchanged. These are existing result and resource-ownership requirements, not a request for a new installation capability.

Actual behavior

The independent focused run passed four characterization/control checks and failed both contract checks with the same rejection. On the success path, the package-manager and ready-launcher callbacks both completed before cleanup ran once and threw. On the failure path, cleanup ran once after the package-manager callback returned false, but the specific package-manager failure was replaced by the same exception. There was no resolved result in either case.

Tests: 2 failed | 4 passed (6)
AssertionError: promise rejected "Error: temporary cleanup denied" instead of resolving
Caused by: Error: temporary cleanup denied

Source inspection establishes that production setup awaits this installer without retaining its temporary directory for later cleanup. Setup does catch the exception for final resource handling, then rethrows it; that handling does not recover the completed installation result. No physical directory leak, rollback, production failure frequency, or Linux package-manager behavior is claimed from the injected fixture.

Evidence

  • Expected source: AGENTS.md:19 requires retaining ownership after failed cleanup; AGENTS.md:29 requires expected failures to preserve meaningful reasons; src/application/LinuxHopper.ts:112 defines the structured Linux installation result contract.
  • Failure source: The fixture above independently reproduces both overwritten outcomes at src/application/LinuxHopper.ts:225, where the cleanup await sits in finally outside the installation catch. The production host at src/application/LinuxHopper.ts:234 has no retained cleanup capability. src/application/SetupHost.ts:145 passes the Linux result into setup, and src/application/SetupHost.ts:234 closes other resource owners without retaining this directory.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete
  • Reachability: With setup approved and Hopper installation selected on a supported Linux host, the real setup workflow awaits installation at src/application/Setup.ts:371. Its outer exception handling at src/application/Setup.ts:42 closes setup resources and rethrows rather than returning the original install result. This caller behavior was established from source, not executed through a real Linux setup command.
  • Verification boundary: The fixture exercises the actual installer with injected effects. Its release metadata satisfies the existing integrity checks; it does not exercise real download, system-package installation, or an actual filesystem permission failure.

Restoration check

Using the same supported-host fixture, require failed cleanup to preserve both the successful installation result and the package-manager failure reason, while separately making cleanup failure visible. Replace the two characterization expectations of rejection with assertions for the corrected contract rather than preserving rejection as desired behavior. Keep the two cleanup-success controls and their exactly-once cleanup observations, so restoration cannot pass by skipping cleanup or always reporting a cleanup warning.

At the production setup-owner boundary, demonstrate that a failed temporary-directory removal retains safe retry ownership, that a later successful retry releases it, and that setup reporting preserves the underlying installation outcome while reporting any remaining cleanup failure. This should not require a particular field name or callback design. No source fix or restored behavior has been implemented as part of this report.

Runtime or environment

Failure verified at a2d8b94dd5 and still present at 8b181716ac. Node.js 26.10.0, npm 11.19.1, and Vitest 5.0.3 with existing lockfile dependencies. The execution used a supported Linux host fixture rather than a real Linux package-manager run.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A failing temporary-removal effect discards a completed installation result or its specific failure reason, and production setup does not retain ownership needed to retry that cleanup. This is consequential bounded result/lifecycle failure; the probe establishes neither data loss nor failure of all primary analysis workflows. Production frequency was not measured.
  • Workaround status: unverified
  • Workaround basis: Successful-cleanup controls return the expected results, but no practical recovery for an already-failed production removal was demonstrated. Manually finding and removing the directory was not tested and would not recover the original structured installation result.
Originally created by @coygeek on GitHub (Oct 13, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2559 ## Summary When Linux Hopper installation reaches temporary-directory cleanup and that removal fails, the installer rejects instead of returning the completed installation result. This happens after a successful package installation with a ready launcher and after a package-manager failure that should have its own structured reason. The caller loses the installation outcome, and the production setup owner has no retained cleanup capability for the temporary directory. The failure was reproduced at the actual installer boundary with injected host effects; no real package installation or Linux filesystem failure was performed. ## Steps to reproduce 1. Use the tested source baseline and installed lockfile dependencies described under Runtime or environment. Save the following fixture as `src/application/hopperCleanupOutcome.test.ts`. It uses a supported Ubuntu 24.04 distribution, three download bytes with matching release length and SHA-1, and a ready launcher; it performs no network, filesystem, or package-manager operations. 2. Run the focused test command below. The two cleanup-success controls pass. The two rejection-characterization checks pass, confirming that cleanup replaces either completed outcome. The two contract checks fail because they require the installation outcome to remain available despite failed cleanup. ```ts import { createHash } from "node:crypto"; import { expect, it } from "vitest"; import { installLinuxHopper, type LinuxHopperInstallHost, } from "./LinuxHopper.js"; function fixture(cleanupFails: boolean, installSucceeds = true) { const bytes = new Uint8Array([1, 2, 3]); const release = { filename: "https://example.invalid/Hopper.deb", file_length: String(bytes.byteLength), file_hash: createHash("sha1").update(bytes).digest("hex"), }; const calls: string[] = []; const host: LinuxHopperInstallHost = { distribution: async () => ({ id: "ubuntu", versionId: "24.04", packageFamily: "deb", supported: true, }), download: async () => ({ ok: true, bytes }), createTemporaryDirectory: async () => "owned-hopper-fixture", writeArchive: async () => undefined, installPackage: async () => { calls.push("installPackage"); return installSucceeds; }, launcherStatus: async () => { calls.push("launcherStatus:ready"); return "ready"; }, cleanup: async (path) => { calls.push(`cleanup:${path}`); if (cleanupFails) throw new Error("temporary cleanup denied"); }, }; return { calls, run: () => installLinuxHopper(host, { releases: { deb: release, rpm: release, arch: release } }), }; } it("returns the successful install result when cleanup succeeds", async () => { const { calls, run } = fixture(false); await expect(run()).resolves.toEqual({ status: "installed", launcherPath: "/opt/hopper/bin/Hopper" }); expect(calls).toEqual(["installPackage", "launcherStatus:ready", "cleanup:owned-hopper-fixture"]); }); it("returns the package-manager failure when cleanup succeeds", async () => { const { calls, run } = fixture(false, false); await expect(run()).resolves.toEqual({ status: "failed", reason: "authorization_or_package_manager" }); expect(calls).toEqual(["installPackage", "cleanup:owned-hopper-fixture"]); }); it("observes cleanup replacing the successful install result", async () => { const { calls, run } = fixture(true); await expect(run()).rejects.toThrow("temporary cleanup denied"); expect(calls).toEqual(["installPackage", "launcherStatus:ready", "cleanup:owned-hopper-fixture"]); }); it("observes cleanup replacing the package-manager failure result", async () => { const { calls, run } = fixture(true, false); await expect(run()).rejects.toThrow("temporary cleanup denied"); expect(calls).toEqual(["installPackage", "cleanup:owned-hopper-fixture"]); }); it("preserves the successful install outcome despite cleanup failure", async () => { const { run } = fixture(true); await expect(run()).resolves.toMatchObject({ status: "installed", launcherPath: "/opt/hopper/bin/Hopper" }); }); it("preserves the package-manager failure despite cleanup failure", async () => { const { run } = fixture(true, false); await expect(run()).resolves.toMatchObject({ status: "failed", reason: "authorization_or_package_manager" }); }); ``` ```sh npm run test:focused -- src/application/hopperCleanupOutcome.test.ts ``` ## Expected behavior After the supported Linux installation attempt completes, the caller should still receive its installed status and launcher path, or its specific installation-failure reason, even if removing the owned temporary directory fails. Failed cleanup should remain visible, and the responsible owner should retain enough information or capability to retry removal safely. A successful cleanup should leave the normal installation result unchanged. These are existing result and resource-ownership requirements, not a request for a new installation capability. ## Actual behavior The independent focused run passed four characterization/control checks and failed both contract checks with the same rejection. On the success path, the package-manager and ready-launcher callbacks both completed before cleanup ran once and threw. On the failure path, cleanup ran once after the package-manager callback returned false, but the specific package-manager failure was replaced by the same exception. There was no resolved result in either case. ```text Tests: 2 failed | 4 passed (6) AssertionError: promise rejected "Error: temporary cleanup denied" instead of resolving Caused by: Error: temporary cleanup denied ``` Source inspection establishes that production setup awaits this installer without retaining its temporary directory for later cleanup. Setup does catch the exception for final resource handling, then rethrows it; that handling does not recover the completed installation result. No physical directory leak, rollback, production failure frequency, or Linux package-manager behavior is claimed from the injected fixture. ## Evidence - Expected source: `AGENTS.md:19` requires retaining ownership after failed cleanup; `AGENTS.md:29` requires expected failures to preserve meaningful reasons; `src/application/LinuxHopper.ts:112` defines the structured Linux installation result contract. - Failure source: The fixture above independently reproduces both overwritten outcomes at `src/application/LinuxHopper.ts:225`, where the cleanup await sits in finally outside the installation catch. The production host at `src/application/LinuxHopper.ts:234` has no retained cleanup capability. `src/application/SetupHost.ts:145` passes the Linux result into setup, and `src/application/SetupHost.ts:234` closes other resource owners without retaining this directory. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete - Reachability: With setup approved and Hopper installation selected on a supported Linux host, the real setup workflow awaits installation at `src/application/Setup.ts:371`. Its outer exception handling at `src/application/Setup.ts:42` closes setup resources and rethrows rather than returning the original install result. This caller behavior was established from source, not executed through a real Linux setup command. - Verification boundary: The fixture exercises the actual installer with injected effects. Its release metadata satisfies the existing integrity checks; it does not exercise real download, system-package installation, or an actual filesystem permission failure. ## Restoration check Using the same supported-host fixture, require failed cleanup to preserve both the successful installation result and the package-manager failure reason, while separately making cleanup failure visible. Replace the two characterization expectations of rejection with assertions for the corrected contract rather than preserving rejection as desired behavior. Keep the two cleanup-success controls and their exactly-once cleanup observations, so restoration cannot pass by skipping cleanup or always reporting a cleanup warning. At the production setup-owner boundary, demonstrate that a failed temporary-directory removal retains safe retry ownership, that a later successful retry releases it, and that setup reporting preserves the underlying installation outcome while reporting any remaining cleanup failure. This should not require a particular field name or callback design. No source fix or restored behavior has been implemented as part of this report. ## Runtime or environment Failure verified at a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 and still present at 8b181716aca2cb467be499cba226abfb5ae19141. Node.js 26.10.0, npm 11.19.1, and Vitest 5.0.3 with existing lockfile dependencies. The execution used a supported Linux host fixture rather than a real Linux package-manager run. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A failing temporary-removal effect discards a completed installation result or its specific failure reason, and production setup does not retain ownership needed to retry that cleanup. This is consequential bounded result/lifecycle failure; the probe establishes neither data loss nor failure of all primary analysis workflows. Production frequency was not measured. - Workaround status: unverified - Workaround basis: Successful-cleanup controls return the expected results, but no practical recovery for an already-failed production removal was demonstrated. Manually finding and removing the directory was not tested and would not recover the original structured installation result.
bsakaguchi 2026-10-15 18:29:12 +00:00
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Addressed on main in ec56c1da0.

Temporary directory cleanup failure no longer replaces the completed installer result; SetupHost retains and retries cleanup ownership and reports retained path on retry failure.

Validation: Linux Hopper source suite 45 passed; global check, docs:check, Python and package verification pass.

Verification scope: No cross-platform real Hopper run.

<!-- gh-comment-id:6158638829 --> @morluto commented on GitHub (Oct 14, 2026): Addressed on main in [ec56c1da0](https://github.com/morluto/rea/commit/ec56c1da0). Temporary directory cleanup failure no longer replaces the completed installer result; SetupHost retains and retries cleanup ownership and reports retained path on retry failure. Validation: Linux Hopper source suite 45 passed; global check, docs:check, Python and package verification pass. Verification scope: No cross-platform real Hopper run.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#876
No description provided.