From 8807e5afb1e9d133ac691f9b572810edb026f341 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Sun, 13 Sep 2026 16:34:25 -0700 Subject: [PATCH] Clear staging an earlier launch abandoned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four prepared staging directories were sitting on the test device, 2.6 MiB each, left by imports that were interrupted rather than cancelled. Intake keeps a verified partial extraction on purpose, so an import the operating system kills can be picked up instead of started over (ADR 0006). #95 built the other half of that — `listResumableBlueskyArchiveIntakes`, which reports what is resumable and destroys staging too damaged to be — and nothing has ever called it, which is the same gap this branch exists to close for the import entry point itself. So the policy produced only debris: a directory the size of the archive being read, per interrupted import, kept until the app is uninstalled. Starting an import sweeps them. It is the only thing that creates them, the person is right there, and anything still in flight belongs to a generation that one has already moved past. The day something offers to resume an import, this is what it replaces. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-bluesky-archive-import.test.ts | 45 +++++++++++++++++++ hooks/use-bluesky-archive-import.ts | 29 ++++++++++++ 2 files changed, 74 insertions(+) diff --git a/hooks/__tests__/use-bluesky-archive-import.test.ts b/hooks/__tests__/use-bluesky-archive-import.test.ts index d5b0d5b..0c96bf2 100644 --- a/hooks/__tests__/use-bluesky-archive-import.test.ts +++ b/hooks/__tests__/use-bluesky-archive-import.test.ts @@ -163,4 +163,49 @@ describe("importing a Cyd Bluesky archive from the menu", () => { expect(result.current.state.status).toBe("idle"); expect(fs.readdirSync(harness.stagingParent)).toEqual([]); }); + + /** + * Intake keeps a verified partial extraction so a killed import can be + * resumed (ADR 0006), and nothing in Mobile offers to resume one — so + * without this, every import the operating system interrupts leaves an + * archive-sized directory behind until the app is uninstalled. + */ + it("clears staging an earlier launch abandoned", async () => { + fs.mkdirSync(path.join(harness.stagingParent, "abandoned-with-nothing"), { + recursive: true, + }); + const halfDone = path.join(harness.stagingParent, "abandoned-part-way"); + fs.mkdirSync(halfDone, { recursive: true }); + fs.writeFileSync( + path.join(halfDone, "intake.json"), + JSON.stringify({ + sourceUri: "file:///fixtures/complete.cyd", + sourceBytes: 1, + phase: "extracting", + confirmedLargeArchive: false, + totalBytes: 1, + metadata: null, + payloads: [], + extracted: [], + updatedAt: new Date().toISOString(), + }), + ); + + const { result } = renderHook(() => + useBlueskyArchiveImport({ + runtime: harness.runtime, + }), + ); + + await act(async () => { + await result.current.start(); + }); + + expect(fs.existsSync(halfDone)).toBe(false); + expect( + fs.readdirSync(harness.stagingParent).filter((name) => + name.startsWith("abandoned-"), + ), + ).toEqual([]); + }); }); diff --git a/hooks/use-bluesky-archive-import.ts b/hooks/use-bluesky-archive-import.ts index 8d3894b..c2cd87f 100644 --- a/hooks/use-bluesky-archive-import.ts +++ b/hooks/use-bluesky-archive-import.ts @@ -5,6 +5,7 @@ import { useCallback, useRef, useState } from "react"; import { cancelBlueskyArchiveIntake, createBlueskyArchiveIntakeEnvironment, + listResumableBlueskyArchiveIntakes, openBlueskyArchiveByteReader, runBlueskyArchiveIntake, type BlueskyArchiveIntakeEnvironment, @@ -128,6 +129,33 @@ function messageFor(error: unknown): string { : "Cyd could not import that archive."; } +/** + * Clear staging that an earlier launch left behind. + * + * Intake keeps a verified partial extraction on purpose, so that an import + * killed by the operating system can be picked up rather than started over + * (ADR 0006). Nothing in Mobile offers to pick one up, so what that policy + * actually produces is a directory per abandoned import, each the size of the + * archive it was reading, kept until the app is uninstalled. + * + * Starting an import is the moment to sweep them: it is the only thing that + * creates them, the person is right there, and anything still in flight + * belongs to a generation this one has already moved past. The day something + * offers to resume an import, this is what it replaces. + */ +function discardAbandonedStaging( + intake: BlueskyArchiveIntakeEnvironment, +): void { + try { + for (const abandoned of listResumableBlueskyArchiveIntakes(intake)) { + cancelBlueskyArchiveIntake(intake, abandoned.intakeId); + } + } catch (error) { + // Debris nobody can see is not worth failing an import over. + console.warn("[archive-import] could not clear old staging", error); + } +} + function plural(count: number, noun: string): string { return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`; } @@ -409,6 +437,7 @@ export function useBlueskyArchiveImport( const start = useCallback(async (): Promise => { const generation = (generationRef.current += 1); try { + discardAbandonedStaging(importRuntime().intake); const picked = await importRuntime().pickArchive(); if (!picked) { return;