Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ describe('main', () => {
__mockOnDidOpenWebView.mockReturnValue(jest.fn());
__mockOnDidCloseWebView.mockReturnValue(jest.fn());
__mockNotificationsSend.mockResolvedValue('mock-notification-id');
jest.mocked(projectStorage.sweepPendingCleanup).mockResolvedValue(0);
});

describe('activate', () => {
Expand All @@ -234,6 +235,33 @@ describe('main', () => {
expect(isWebViewProvider(raw)).toBe(true);
});

it('runs the pending-cleanup sweep with the execution token', async () => {
const context = createTestActivationContext();

await activate(context);

expect(jest.mocked(projectStorage.sweepPendingCleanup)).toHaveBeenCalledWith(
context.executionToken,
);
});

it('logs but does not fail activation when the pending-cleanup sweep rejects', async () => {
jest
.mocked(projectStorage.sweepPendingCleanup)
.mockRejectedValue(new Error('sweep exploded'));
const context = createTestActivationContext();

await expect(activate(context)).resolves.toBeUndefined();

// The rejection resolves on a later microtask than activate()'s synchronous body, so let the
// fire-and-forget catch handler run before asserting it logged.
await Promise.resolve();
expect(__mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('pending-cleanup sweep failed'),
expect.any(Error),
);
});

it('registers the interlinearizer.openForWebView command with a callable handler', async () => {
const context = createTestActivationContext();
await activate(context);
Expand Down
216 changes: 216 additions & 0 deletions src/__tests__/services/projectStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
listProjects,
resetQueuesForTesting,
saveDraft,
sweepPendingCleanup,
updateAnalysis,
updateProjectMetadata,
} from '../../services/projectStorage';
Expand Down Expand Up @@ -184,6 +185,78 @@ describe('projectStorage', () => {

expect(__mockLogger.error).toHaveBeenCalled();
});

it('records the orphaned project for cleanup when rollback fails', async () => {
// Index read (ENOENT → []) and the later pendingCleanup read (ENOENT → []) both miss.
__mockReadUserData.mockRejectedValue(enoentError());
__mockWriteUserData
.mockResolvedValueOnce(undefined) // project write succeeds
.mockRejectedValueOnce(new Error('disk full')); // index write fails
__mockDeleteUserData.mockRejectedValue(new Error('rollback failed'));

await expect(createProject(token, 'src-proj', ['en'])).rejects.toThrow('disk full');

expect(__mockWriteUserData).toHaveBeenCalledWith(
token,
'pendingCleanup',
JSON.stringify(['00000000-0000-0000-0000-000000000001']),
);
});

it('logs and swallows a failure to record the orphan so the index error still surfaces', async () => {
__mockReadUserData.mockRejectedValue(enoentError());
// project write ok; index write fails; pendingCleanup write also fails.
__mockWriteUserData
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('disk full'))
.mockRejectedValueOnce(new Error('cleanup write failed'));
__mockDeleteUserData.mockRejectedValue(new Error('rollback failed'));

await expect(createProject(token, 'src-proj', ['en'])).rejects.toThrow('disk full');

expect(__mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('record orphaned project'),
expect.any(Error),
);
});

it('does not re-record an orphan already in the pending-cleanup set', async () => {
const orphanId = '00000000-0000-0000-0000-000000000001';
// Index read → ENOENT ([]); pendingCleanup read → already contains this orphan.
__mockReadUserData.mockImplementation((_t: unknown, key: unknown) =>
key === 'pendingCleanup'
? Promise.resolve(JSON.stringify([orphanId]))
: Promise.reject(enoentError()),
);
__mockWriteUserData
.mockResolvedValueOnce(undefined) // project write succeeds
.mockRejectedValueOnce(new Error('disk full')); // index write fails
__mockDeleteUserData.mockRejectedValue(new Error('rollback failed'));

await expect(createProject(token, 'src-proj', ['en'])).rejects.toThrow('disk full');

expect(__mockWriteUserData).not.toHaveBeenCalledWith(
token,
'pendingCleanup',
expect.anything(),
);
});

it('does not record the orphan when the rollback delete succeeds', async () => {
__mockReadUserData.mockRejectedValue(enoentError());
__mockWriteUserData
.mockResolvedValueOnce(undefined) // project write succeeds
.mockRejectedValueOnce(new Error('disk full')); // index write fails
// deleteUserData resolves (default mock) → rollback succeeds, nothing to record.

await expect(createProject(token, 'src-proj', ['en'])).rejects.toThrow('disk full');

expect(__mockWriteUserData).not.toHaveBeenCalledWith(
token,
'pendingCleanup',
expect.anything(),
);
});
});

describe('getProject', () => {
Expand Down Expand Up @@ -413,6 +486,149 @@ describe('projectStorage', () => {
});
});

describe('sweepPendingCleanup', () => {
/**
* Makes `readUserData` return `pendingCleanup` as the given id list and ENOENT for every other
* key, so a sweep sees exactly `ids` as its work set.
*
* @param ids - The project IDs the pending-cleanup set should contain.
*/
function stubPendingCleanup(ids: string[]): void {
__mockReadUserData.mockImplementation((_t: unknown, key: unknown) =>
key === 'pendingCleanup'
? Promise.resolve(JSON.stringify(ids))
: Promise.reject(enoentError()),
);
}

it('returns 0 and writes nothing when the set is empty', async () => {
stubPendingCleanup([]);

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(0);
expect(__mockDeleteUserData).not.toHaveBeenCalled();
expect(__mockWriteUserData).not.toHaveBeenCalled();
});

it('deletes each recorded record and clears the set on full success', async () => {
stubPendingCleanup(['orphan-a', 'orphan-b']);

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(2);
expect(__mockDeleteUserData).toHaveBeenCalledWith(token, 'project:orphan-a');
expect(__mockDeleteUserData).toHaveBeenCalledWith(token, 'project:orphan-b');
expect(__mockWriteUserData).toHaveBeenCalledWith(token, 'pendingCleanup', JSON.stringify([]));
});

it('treats an already-missing record (ENOENT) as successfully cleaned', async () => {
stubPendingCleanup(['gone']);
__mockDeleteUserData.mockRejectedValue(enoentError());

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(1);
expect(__mockWriteUserData).toHaveBeenCalledWith(token, 'pendingCleanup', JSON.stringify([]));
});

it('retains an id whose deletion fails again and logs it', async () => {
stubPendingCleanup(['stubborn', 'ok']);
__mockDeleteUserData.mockImplementation((_t: unknown, key: unknown) =>
key === 'project:stubborn'
? Promise.reject(new Error('still locked'))
: Promise.resolve(undefined),
);

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(1);
expect(__mockWriteUserData).toHaveBeenCalledWith(
token,
'pendingCleanup',
JSON.stringify(['stubborn']),
);
expect(__mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('stubborn'),
expect.any(Error),
);
});

it('propagates a non-ENOENT error from reading the pending-cleanup set', async () => {
__mockReadUserData.mockRejectedValue(new Error('disk full'));

await expect(sweepPendingCleanup(token)).rejects.toThrow('disk full');
});

it('does not rewrite the set when no ids could be cleaned', async () => {
stubPendingCleanup(['stubborn']);
__mockDeleteUserData.mockRejectedValue(new Error('still locked'));

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(0);
expect(__mockWriteUserData).not.toHaveBeenCalled();
});

it('never deletes the record of an id still present in the index', async () => {
// 'live' is both recorded for cleanup and still in the index (e.g. an index write that
// persisted but reported failure). Its backing record must not be deleted.
__mockReadUserData.mockImplementation((_t: unknown, key: unknown) => {
if (key === 'pendingCleanup') return Promise.resolve(JSON.stringify(['live', 'orphan']));
if (key === 'projectIds') return Promise.resolve(JSON.stringify(['live']));
return Promise.reject(enoentError());
});

const cleaned = await sweepPendingCleanup(token);

expect(__mockDeleteUserData).not.toHaveBeenCalledWith(token, 'project:live');
expect(__mockDeleteUserData).toHaveBeenCalledWith(token, 'project:orphan');
// 'live' is only a real orphan record when deleted; it was skipped, so it is not counted.
expect(cleaned).toBe(1);
});

it('drops a live id from the set without counting or deleting it', async () => {
__mockReadUserData.mockImplementation((_t: unknown, key: unknown) => {
if (key === 'pendingCleanup') return Promise.resolve(JSON.stringify(['live']));
if (key === 'projectIds') return Promise.resolve(JSON.stringify(['live']));
return Promise.reject(enoentError());
});

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(0);
expect(__mockDeleteUserData).not.toHaveBeenCalled();
// The set is rewritten to drop the non-orphan id even though nothing was deleted.
expect(__mockWriteUserData).toHaveBeenCalledWith(token, 'pendingCleanup', JSON.stringify([]));
});

it('resets a pending-cleanup value containing invalid JSON to empty instead of wedging', async () => {
__mockReadUserData.mockImplementation((_t: unknown, key: unknown) =>
key === 'pendingCleanup' ? Promise.resolve('{ not json') : Promise.reject(enoentError()),
);

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(0);
expect(__mockDeleteUserData).not.toHaveBeenCalled();
expect(__mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
});

it('resets a pending-cleanup value that is not an array of strings to empty', async () => {
__mockReadUserData.mockImplementation((_t: unknown, key: unknown) =>
key === 'pendingCleanup'
? Promise.resolve(JSON.stringify([1, 2, 3]))
: Promise.reject(enoentError()),
);

const cleaned = await sweepPendingCleanup(token);

expect(cleaned).toBe(0);
expect(__mockDeleteUserData).not.toHaveBeenCalled();
expect(__mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('not an array'));
});
});

describe('updateAnalysis', () => {
const storedProject = makeStubProject('proj-id');
const newAnalysis = {
Expand Down
7 changes: 7 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,13 @@ export async function activate(context: ExecutionActivationContext): Promise<voi

executionToken = context.executionToken;

// Opportunistically retry deleting any project records orphaned by a failed rollback on a prior
// run (see projectStorage.createProject). Fire-and-forget: a cleanup failure must never block or
// fail activation, and the sweep will run again on the next activation.
projectStorage.sweepPendingCleanup(executionToken).catch((e) => {
logger.error('Interlinearizer: pending-cleanup sweep failed during activation:', e);
});

const mainWebViewProviderRegistration = await papi.webViewProviders.registerWebViewProvider(
mainWebViewType,
mainWebViewProvider,
Expand Down
Loading
Loading