Skip to content

Commit aab1965

Browse files
Gnathonicclaude
andcommitted
fix(mega): stop ghost nodes from failing every sync with ENOENT
A node deleted server-side becomes a permanent "ghost" in megajs's storage.files: the sc delete handler only unlinks parent.children, and reload() is purely additive (_importFile skips known handles). Since the provider enumerates from storage.files, a ghost volume-data.json made every sync fail: the download discarded the readable copy (Promise.all), and the pre-upload replace delete hit server ENOENT (-9) with a cache-refresh retry that could never evict the ghost. - reinitialize(): rebuild storage.files from an empty map so the reload mirrors the server exactly (evicts ghosts); restore the old tree on transient reload failure. Also strip the stacked api 'sc' listeners megajs re-attaches on every reload — double-processed delete packets splice(-1) and corrupt sibling arrays in long sessions. - uploadFile(): replace-all semantics (delete every same-name copy, not just the first) and treat a server "already gone" delete answer as converged instead of failing the upload. - downloadVolumeDataFile(): Promise.allSettled — merge the readable duplicates, skip not-found ghosts, keep the first readable copy, and tolerate NOT_FOUND when deleting the extras. Transient errors still propagate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ef8f846 commit aab1965

4 files changed

Lines changed: 373 additions & 24 deletions

File tree

src/lib/util/sync/providers/mega/mega-provider.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,116 @@ describe('MegaProvider.removeDirectoryIfEmpty()', () => {
382382
expect(storage.api.request).not.toHaveBeenCalled();
383383
});
384384
});
385+
386+
describe('MegaProvider ghost-node handling', () => {
387+
// A "ghost" is a node deleted server-side that megajs never evicts from
388+
// storage.files: its sc delete handler only unlinks parent.children, and
389+
// reload() is purely additive (_importFile skips known handles). Ghosts
390+
// made every sync fail: the pre-upload replace delete hit server ENOENT (-9)
391+
// and the reload-based retry could never remove the ghost.
392+
393+
function makeMokuroFolder() {
394+
return {
395+
name: 'mokuro-reader',
396+
directory: true,
397+
upload: vi.fn((_opts: any, _buf: any, cb: (e: Error | null, f?: any) => void) => {
398+
queueMicrotask(() => cb(null, { nodeId: 'fresh-node' }));
399+
})
400+
} as any;
401+
}
402+
403+
function makeFile(name: string, parent: any, deleteError: Error | null = null) {
404+
return {
405+
name,
406+
directory: false,
407+
parent,
408+
delete: vi.fn((_force: boolean, cb: (e: Error | null) => void) => {
409+
queueMicrotask(() => cb(deleteError));
410+
})
411+
} as any;
412+
}
413+
414+
async function loginWithTree(files: Record<string, any>) {
415+
storageState.files = files;
416+
const provider = new MegaProvider();
417+
await provider.whenReady();
418+
await provider.login({ email: 'a@b.c', password: 'secret' });
419+
return provider;
420+
}
421+
422+
it('uploadFile tolerates ENOENT when deleting a ghost copy and still uploads', async () => {
423+
const folder = makeMokuroFolder();
424+
const ghost = makeFile(
425+
'volume-data.json',
426+
folder,
427+
new Error('ENOENT (-9): Object (typically, node or user) not found. Wrong password?')
428+
);
429+
const provider = await loginWithTree({ root: folder, ghost });
430+
431+
const fileId = await provider.uploadFile('volume-data.json', new Uint8Array([1, 2, 3]));
432+
433+
expect(ghost.delete).toHaveBeenCalled();
434+
expect(folder.upload).toHaveBeenCalledOnce();
435+
expect(fileId).toBe('fresh-node');
436+
});
437+
438+
it('uploadFile replaces every same-name copy, not just the first', async () => {
439+
const folder = makeMokuroFolder();
440+
const dupe1 = makeFile('volume-data.json', folder);
441+
const dupe2 = makeFile('volume-data.json', folder);
442+
const provider = await loginWithTree({ root: folder, dupe1, dupe2 });
443+
444+
await provider.uploadFile('volume-data.json', new Uint8Array([1]));
445+
446+
expect(dupe1.delete).toHaveBeenCalledOnce();
447+
expect(dupe2.delete).toHaveBeenCalledOnce();
448+
expect(folder.upload).toHaveBeenCalledOnce();
449+
});
450+
451+
it('reinitialize rebuilds storage.files so server-deleted ghosts are evicted', async () => {
452+
const folder = makeMokuroFolder();
453+
const ghost = makeFile('volume-data.json', folder);
454+
const real = makeFile('real.cbz', folder);
455+
const provider = await loginWithTree({ root: folder, ghost });
456+
const storage = (provider as any).storage;
457+
458+
// Mirror megajs reload() semantics: additive import of the server's
459+
// node set (which no longer contains the ghost).
460+
storage.reload = vi.fn(async () => {
461+
const server: Record<string, any> = { root: folder, real };
462+
for (const [handle, node] of Object.entries(server)) {
463+
if (!storage.files[handle]) storage.files[handle] = node;
464+
}
465+
});
466+
467+
await (provider as any).reinitialize();
468+
469+
expect(Object.keys(storage.files).sort()).toEqual(['real', 'root']);
470+
});
471+
472+
it('reinitialize strips stale sc listeners before reloading (megajs stacks one per reload)', async () => {
473+
const folder = makeMokuroFolder();
474+
const provider = await loginWithTree({ root: folder });
475+
const storage = (provider as any).storage;
476+
storage.api = { removeAllListeners: vi.fn() };
477+
478+
await (provider as any).reinitialize();
479+
480+
expect(storage.api.removeAllListeners).toHaveBeenCalledWith('sc');
481+
});
482+
483+
it('reinitialize keeps the previous tree when the reload fails transiently', async () => {
484+
const folder = makeMokuroFolder();
485+
const ghost = makeFile('volume-data.json', folder);
486+
const provider = await loginWithTree({ root: folder, ghost });
487+
const storage = (provider as any).storage;
488+
storage.reload = vi.fn(async () => {
489+
throw new Error('ETEMPUNAVAIL (-18): A temporary congestion or server malfunction');
490+
});
491+
492+
await (provider as any).reinitialize();
493+
494+
expect(provider.isAuthenticated()).toBe(true);
495+
expect(Object.keys(storage.files).sort()).toEqual(['ghost', 'root']);
496+
});
497+
});

src/lib/util/sync/providers/mega/mega-provider.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ async function retryWithBackoff<T>(
9494
throw lastError;
9595
}
9696

97+
/** megajs surfaces a server-side missing node as `ENOENT (-9)`. */
98+
function isMegaNotFoundError(error: unknown): boolean {
99+
const message = error instanceof Error ? error.message : String(error);
100+
return message.includes('ENOENT') || message.includes('(-9)');
101+
}
102+
97103
/**
98104
* Smart retry wrapper for MEGA operations that may fail due to stale cache
99105
* When two devices sync back and forth, file IDs change but local cache is stale
@@ -443,9 +449,29 @@ export class MegaProvider implements SyncProvider {
443449
// server-side. Every storage (login, restore, reinitialize, upload worker)
444450
// reuses the one persisted sid, so closing any of them invalidates the
445451
// stored token and makes every later request fail with ESID (-15).
446-
await this.storage.reload(true);
452+
//
453+
// Two megajs reload() quirks force extra work here:
454+
// - reload() attaches a NEW api 'sc' handler on every call without
455+
// removing the old one. Stacked handlers process each server packet
456+
// N times, and a re-processed delete splices with indexOf === -1,
457+
// silently removing an unrelated sibling from the tree.
458+
// - reload() only ADDS nodes (_importFile skips known handles) and the
459+
// sc delete handler never removes nodes from storage.files, so a node
460+
// deleted server-side survives every reload as a permanent "ghost"
461+
// that fails all later operations with ENOENT (-9). Rebuild from an
462+
// empty map so the reloaded tree exactly mirrors the server.
463+
this.storage.api?.removeAllListeners?.('sc');
464+
const previousFiles = this.storage.files;
465+
this.storage.files = {};
466+
try {
467+
await this.storage.reload(true);
468+
} catch (error) {
469+
// Keep the old (stale but usable) tree if the reload didn't complete.
470+
this.storage.files = previousFiles;
471+
throw error;
472+
}
447473
this.mokuroFolder = null;
448-
console.log('✅ MEGA cache reinitialized (in-place reload)');
474+
console.log('✅ MEGA cache reinitialized (fresh in-place reload)');
449475
} catch (error) {
450476
if (isSessionExpiredError(error)) {
451477
this.markSessionExpired();
@@ -713,18 +739,21 @@ export class MegaProvider implements SyncProvider {
713739
buffer = new Uint8Array(arrayBuffer);
714740
}
715741

716-
// Check if file already exists
717742
const children = await this.listFolder(targetFolder);
718-
const existingFile = children.find((f: any) => f.name === fileName && !f.directory);
719743

720-
// Delete existing file if found. Do NOT remove it from
721-
// storage.files manually — the sc stream delivers the delete and
722-
// megajs applies it; a manual removal makes the later sc packet
723-
// crash on a missing node (the original "keepalive crash").
724-
if (existingFile) {
744+
// Replace semantics: delete EVERY same-name copy (MEGA allows
745+
// duplicate names, so racing uploads can leave several). Do NOT
746+
// remove them from storage.files manually — the sc stream delivers
747+
// the delete and megajs applies it; a manual removal makes the
748+
// later sc packet crash on a missing node (the original "keepalive
749+
// crash"). A copy can also be a ghost — deleted server-side but
750+
// never evicted from storage.files by megajs — so a server
751+
// "already gone" answer means converged, not failed.
752+
const existingFiles = children.filter((f: any) => f.name === fileName && !f.directory);
753+
for (const existingFile of existingFiles) {
725754
await new Promise<void>((resolve, reject) => {
726755
existingFile.delete(true, (error: Error | null) => {
727-
if (error) reject(error);
756+
if (error && !isMegaNotFoundError(error)) reject(error);
728757
else resolve();
729758
});
730759
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { ProviderError } from './provider-interface';
3+
import type { CloudFileMetadata, SyncProvider } from './provider-interface';
4+
5+
const getCache = vi.fn();
6+
7+
vi.mock('./cache-manager', () => ({
8+
cacheManager: { getCache: (...args: unknown[]) => getCache(...args) }
9+
}));
10+
11+
vi.mock('../snackbar', () => ({ showSnackbar: vi.fn() }));
12+
13+
vi.mock('../progress-tracker', () => ({
14+
progressTrackerStore: {
15+
addProcess: vi.fn(),
16+
updateProcess: vi.fn(),
17+
removeProcess: vi.fn()
18+
}
19+
}));
20+
21+
vi.mock('$lib/settings', async () => {
22+
const { writable } = await import('svelte/store');
23+
return {
24+
volumesWithTrash: writable({}),
25+
profiles: writable({}),
26+
profilesWithTrash: writable({}),
27+
migrateProfiles: vi.fn((p: unknown) => p),
28+
parseVolumesFromJson: vi.fn((json: string) => JSON.parse(json))
29+
};
30+
});
31+
32+
import { unifiedSyncService } from './unified-sync-service';
33+
34+
// downloadVolumeDataFile is private; these tests target it directly because it
35+
// owns the duplicate-merge behavior that broke MEGA sync (ghost duplicates).
36+
const svc = unifiedSyncService as any;
37+
38+
function fileMeta(fileId: string): CloudFileMetadata {
39+
return {
40+
provider: 'mega',
41+
fileId,
42+
path: 'volume-data.json',
43+
modifiedTime: '2026-01-01T00:00:00Z'
44+
} as unknown as CloudFileMetadata;
45+
}
46+
47+
const jsonBlob = (data: unknown) => ({ text: async () => JSON.stringify(data) }) as unknown as Blob;
48+
49+
const notFound = () => new ProviderError('File not found: volume-data.json', 'mega', 'NOT_FOUND');
50+
51+
function makeProvider(
52+
download: (file: CloudFileMetadata) => Promise<Blob>,
53+
del: (file: CloudFileMetadata) => Promise<void> = async () => {}
54+
): SyncProvider {
55+
return {
56+
type: 'mega',
57+
downloadFile: vi.fn(download),
58+
deleteFile: vi.fn(del)
59+
} as unknown as SyncProvider;
60+
}
61+
62+
function stubCache(files: CloudFileMetadata[]) {
63+
const cache = {
64+
getAll: vi.fn(() => files),
65+
get: vi.fn(() => null),
66+
fetch: vi.fn(async () => {})
67+
};
68+
getCache.mockReturnValue(cache);
69+
return cache;
70+
}
71+
72+
beforeEach(() => {
73+
vi.clearAllMocks();
74+
});
75+
76+
describe('downloadVolumeDataFile — duplicate handling with ghost copies', () => {
77+
const goodData = { 'vol-1': { lastProgressUpdate: '2026-01-02T00:00:00Z', progress: 5 } };
78+
79+
it('merges the readable copies and skips a ghost duplicate instead of discarding everything', async () => {
80+
const [good, ghost] = [fileMeta('good'), fileMeta('ghost')];
81+
const cache = stubCache([good, ghost]);
82+
const provider = makeProvider(async (file) => {
83+
if (file.fileId === 'ghost') throw notFound();
84+
return jsonBlob(goodData);
85+
});
86+
87+
const result = await svc.downloadVolumeDataFile(provider);
88+
89+
expect(result).toEqual(goodData);
90+
expect(provider.deleteFile).toHaveBeenCalledTimes(1);
91+
expect(provider.deleteFile).toHaveBeenCalledWith(ghost);
92+
expect(cache.fetch).not.toHaveBeenCalled();
93+
});
94+
95+
it('keeps the readable copy when the FIRST listed duplicate is the ghost', async () => {
96+
const [ghost, good] = [fileMeta('ghost'), fileMeta('good')];
97+
stubCache([ghost, good]);
98+
const provider = makeProvider(async (file) => {
99+
if (file.fileId === 'ghost') throw notFound();
100+
return jsonBlob(goodData);
101+
});
102+
103+
const result = await svc.downloadVolumeDataFile(provider);
104+
105+
expect(result).toEqual(goodData);
106+
expect(provider.deleteFile).toHaveBeenCalledTimes(1);
107+
expect(provider.deleteFile).toHaveBeenCalledWith(ghost);
108+
});
109+
110+
it('tolerates NOT_FOUND from deleting a ghost duplicate (already converged)', async () => {
111+
const [good, ghost] = [fileMeta('good'), fileMeta('ghost')];
112+
stubCache([good, ghost]);
113+
const provider = makeProvider(
114+
async (file) => {
115+
if (file.fileId === 'ghost') throw notFound();
116+
return jsonBlob(goodData);
117+
},
118+
async () => {
119+
throw notFound();
120+
}
121+
);
122+
123+
await expect(svc.downloadVolumeDataFile(provider)).resolves.toEqual(goodData);
124+
});
125+
126+
it('returns null after one cache refresh when every copy is missing', async () => {
127+
const cache = stubCache([fileMeta('ghost-1'), fileMeta('ghost-2')]);
128+
const provider = makeProvider(async () => {
129+
throw notFound();
130+
});
131+
132+
const result = await svc.downloadVolumeDataFile(provider);
133+
134+
expect(result).toBeNull();
135+
expect(cache.fetch).toHaveBeenCalledTimes(1);
136+
expect(provider.deleteFile).not.toHaveBeenCalled();
137+
});
138+
139+
it('merges duplicates newest-lastProgressUpdate-wins and deletes the extra copy', async () => {
140+
const [first, second] = [fileMeta('first'), fileMeta('second')];
141+
stubCache([first, second]);
142+
const newerData = {
143+
'vol-1': { lastProgressUpdate: '2026-01-03T00:00:00Z', progress: 9 },
144+
'vol-2': { lastProgressUpdate: '2026-01-01T00:00:00Z', progress: 1 }
145+
};
146+
const provider = makeProvider(async (file) =>
147+
jsonBlob(file.fileId === 'first' ? goodData : newerData)
148+
);
149+
150+
const result = await svc.downloadVolumeDataFile(provider);
151+
152+
expect(result['vol-1'].progress).toBe(9);
153+
expect(result['vol-2'].progress).toBe(1);
154+
expect(provider.deleteFile).toHaveBeenCalledTimes(1);
155+
expect(provider.deleteFile).toHaveBeenCalledWith(second);
156+
});
157+
158+
it('propagates transient download errors rather than treating them as missing data', async () => {
159+
stubCache([fileMeta('good'), fileMeta('flaky')]);
160+
const provider = makeProvider(async (file) => {
161+
if (file.fileId === 'flaky') throw new Error('network down');
162+
return jsonBlob(goodData);
163+
});
164+
165+
await expect(svc.downloadVolumeDataFile(provider)).rejects.toThrow('network down');
166+
expect(provider.deleteFile).not.toHaveBeenCalled();
167+
});
168+
});

0 commit comments

Comments
 (0)