fix: remove shared-file copies left behind by an upload cut short

Files shared into the app are copied into the cache as `upload-*`
before they are uploaded. When the app was killed mid-upload the copy
was never deleted. The launch-time sweep of stale export files now also
removes upload copies made by an earlier run of the app; copies of the
current run may still be uploading and are left alone.
This commit is contained in:
Linus Rath
2026-09-24 19:45:06 +02:00
parent 2e78c9870c
commit 3c7b96e11a
4 changed files with 63 additions and 5 deletions
+21 -1
View File
@@ -40,7 +40,7 @@ vi.mock('../jmap-client', () => ({
},
}));
import { getDownloadUrl, uploadBlob } from '../blob';
import { getDownloadUrl, isStaleUploadCopy, uploadBlob } from '../blob';
beforeEach(() => {
vi.clearAllMocks();
@@ -165,3 +165,23 @@ describe('uploadBlob', () => {
expect(createUploadTask).not.toHaveBeenCalled();
});
});
describe('isStaleUploadCopy', () => {
it('flags upload copies an earlier run of the app left behind', () => {
expect(isStaleUploadCopy('upload-1700000000000-3')).toBe(true);
});
it("leaves this run's copies alone, since they may still be uploading", async () => {
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 200, body: JSON.stringify({ blobId: 'b' }) });
await uploadBlob('content://com.example.provider/doc/9', 'image/png');
const name = copyAsync.mock.calls[0][0].to.replace('file:///cache/', '');
expect(isStaleUploadCopy(name)).toBe(false);
});
it('ignores other cache files', () => {
expect(isStaleUploadCopy('upload-notes.txt')).toBe(false);
expect(isStaleUploadCopy('bulwark-exports')).toBe(false);
expect(isStaleUploadCopy('x-upload-1700000000000-3')).toBe(false);
});
});
+12
View File
@@ -107,6 +107,18 @@ export async function uploadBlob(
}
let cacheCopySeq = 0;
const UPLOAD_COPY_NAME = /^upload-(\d+)-\d+$/;
const loadedAt = Date.now();
/**
* Whether a cache file is an upload copy (see below) made by an earlier run
* of the app: one killed mid-upload never deleted it. Copies of this run may
* still be uploading and are left alone.
*/
export function isStaleUploadCopy(name: string): boolean {
const match = UPLOAD_COPY_NAME.exec(name);
return !!match && Number(match[1]) < loadedAt;
}
// The native upload task reads from a file path, but files shared into the
// app from another app arrive as Android `content://` URIs with nothing on
+20 -1
View File
@@ -65,7 +65,10 @@ vi.mock('expo-file-system', () => {
return {
File,
Directory,
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
Paths: {
cache: { uri: 'file:///cache/', list: () => fsState.listings.get('file:///cache/') ?? [] },
document: { uri: 'file:///documents/' },
},
};
});
@@ -97,6 +100,7 @@ vi.mock('../../api/jmap-client', () => ({
vi.mock('../../api/blob', () => ({
getDownloadUrl: vi.fn((blobId: string, name: string) => `https://mail.example.com/${blobId}/${name}`),
isStaleUploadCopy: (name: string) => name.startsWith('upload-old-'),
}));
import * as IntentLauncher from 'expo-intent-launcher';
@@ -356,4 +360,19 @@ describe('sweepStaleExportFiles', () => {
handedOff.uri,
]);
});
it('removes the upload copies an earlier run left in the cache', async () => {
vi.resetModules();
const fsMod = await import('expo-file-system');
const { sweepStaleExportFiles } = await import('../email-export');
fsState.deleted.length = 0;
fsState.listings.clear();
const cache = { uri: 'file:///cache/' };
fsState.listings.set(cache.uri, ['upload-old-1', 'upload-current-2', 'image-cache.png']
.map((name) => new fsMod.File(cache as never, name)));
await sweepStaleExportFiles();
expect(fsState.deleted).toEqual(['file:///cache/upload-old-1']);
});
});
+10 -3
View File
@@ -3,7 +3,7 @@ import { Directory, File, Paths } from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
import { getDownloadUrl, isStaleUploadCopy } from '../api/blob';
import type { Attachment, Email } from '../api/types';
import { useSettingsStore } from '../stores/settings-store';
import {
@@ -89,13 +89,20 @@ function deleteStaleFiles(dir: Directory, now: number, maxAgeMs: number): void {
* Remove temp files older than a day. Files shared into other apps could not
* always be deleted right after the share sheet closed (the receiving app may
* still be reading them), so anything that slipped through is collected here,
* including preview folders whose file went to another app. Runs once per
* process, lazily before the first export, and is also safe to call at launch.
* including preview folders whose file went to another app. Also removes the
* cache copies of shared files that an earlier run was killed while uploading
* (`api/blob`). Runs once per process, lazily before the first export, and is
* also safe to call at launch.
*/
let sweptThisProcess = false;
export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise<void> {
if (sweptThisProcess) return;
sweptThisProcess = true;
try {
for (const entry of Paths.cache.list()) {
if (entry instanceof File && isStaleUploadCopy(entry.name)) deleteQuietly(entry);
}
} catch { /* housekeeping only */ }
try {
const dir = exportsDir();
if (!dir.exists) return;