From 798dbbc7c9ba7d20dbd6df3fefaa7c93963b4e17 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 13 Mar 2026 15:39:52 +0100 Subject: [PATCH 1/4] Fix: centralize temporal drive folder path + add jsdoc for TemporalFile --- .../offline-drive/registerTemporalFilesServices.ts | 8 ++------ .../storage/TemporalFiles/domain/TemporalFile.ts | 11 ++++++++++- src/core/electron/paths.ts | 5 +++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/apps/drive/dependency-injection/offline-drive/registerTemporalFilesServices.ts b/src/apps/drive/dependency-injection/offline-drive/registerTemporalFilesServices.ts index 3a7169100e..05111bb700 100644 --- a/src/apps/drive/dependency-injection/offline-drive/registerTemporalFilesServices.ts +++ b/src/apps/drive/dependency-injection/offline-drive/registerTemporalFilesServices.ts @@ -1,7 +1,5 @@ import { Environment } from '@internxt/inxt-js'; import { ContainerBuilder } from 'diod'; -import { app } from 'electron'; -import path from 'path'; import { UploadProgressTracker } from '../../../../context/shared/domain/UploadProgressTracker'; import { TemporalFileByteByByteComparator } from '../../../../context/storage/TemporalFiles/application/comparation/TemporalFileByteByByteComparator'; import { TemporalFileCreator } from '../../../../context/storage/TemporalFiles/application/creation/TemporalFileCreator'; @@ -18,18 +16,16 @@ import { TemporalFileUploaderFactory } from '../../../../context/storage/Tempora import { NodeTemporalFileRepository } from '../../../../context/storage/TemporalFiles/infrastructure/NodeTemporalFileRepository'; import { EnvironmentTemporalFileUploaderFactory } from '../../../../context/storage/TemporalFiles/infrastructure/upload/EnvironmentTemporalFileUploaderFactory'; import { DependencyInjectionUserProvider } from '../../../shared/dependency-injection/DependencyInjectionUserProvider'; +import { PATHS } from '../../../../core/electron/paths'; export async function registerTemporalFilesServices(builder: ContainerBuilder) { // Infra const user = DependencyInjectionUserProvider.get(); - const temporal = app.getPath('temp'); - const write = path.join(temporal, 'internxt-drive-tmp'); - builder .register(TemporalFileRepository) .useFactory(() => { - const repo = new NodeTemporalFileRepository(write); + const repo = new NodeTemporalFileRepository(PATHS.INTERNXT_DRIVE_TMP); repo.init(); diff --git a/src/context/storage/TemporalFiles/domain/TemporalFile.ts b/src/context/storage/TemporalFiles/domain/TemporalFile.ts index 392f3be7c0..268c8ac12c 100644 --- a/src/context/storage/TemporalFiles/domain/TemporalFile.ts +++ b/src/context/storage/TemporalFiles/domain/TemporalFile.ts @@ -1,7 +1,6 @@ import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; import { TemporalFilePath } from './TemporalFilePath'; import { TemporalFileSize } from './TemporalFileSize'; - export type TemporalFileAttributes = { createdAt: Date; modifiedAt: Date; @@ -9,6 +8,16 @@ export type TemporalFileAttributes = { size: number; }; +/** + * A temporal file is a local staging copy of a file the user is creating/writing on the virtual drive. + * + * When a user drops a file into the Internxt Drive folder (e.g. via Nautilus drag & drop), + * it is stored temporarily at `/tmp/internxt-drive-tmp/{uuid}` while being written. + * Once the file descriptor is closed (FUSE release), the temporal file is uploaded to the cloud + * (see {@link TemporalFileUploader}) and then deleted from disk (see {@link DeleteTemporalFileOnFileCreated}). + * + * Auxiliary files (lock files, .tmp, vim swap, .goutputstream-*) are ignored. + */ export class TemporalFile extends AggregateRoot { private static readonly TEMPORAL_EXTENSION = 'tmp'; private static readonly LOCK_FILE_NAME_PREFIX = '.~lock.'; diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index 71c8f52088..3337da07f2 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -7,9 +7,14 @@ const APP_DATA_PATH = app.getPath('appData'); const INTERNXT = join(APP_DATA_PATH, 'internxt-drive'); const LOGS = join(HOME_FOLDER_PATH, '.config', 'internxt', 'logs'); const THUMBNAILS_FOLDER = path.join(os.homedir(), '.cache', 'thumbnails'); +const TEMPORAL_FOLDER = app.getPath('temp'); +const INTERNXT_DRIVE_TMP = path.join(TEMPORAL_FOLDER, 'internxt-drive-tmp'); + export const PATHS = { HOME_FOLDER_PATH, INTERNXT, LOGS, THUMBNAILS_FOLDER, + TEMPORAL_FOLDER, + INTERNXT_DRIVE_TMP, }; From 41047b78218bed9aec89a44878a6113e27ecc08e Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 13 Mar 2026 15:40:14 +0100 Subject: [PATCH 2/4] Fix: Decouple fuse callback with logic and make tests --- .../fuse/callbacks/ReleaseCallback.test.ts | 45 +++++++ .../drive/fuse/callbacks/ReleaseCallback.ts | 70 +++++------ .../handle-release-callback.test.ts | 110 ++++++++++++++++++ .../on-release/handle-release-callback.ts | 44 +++++++ 4 files changed, 230 insertions(+), 39 deletions(-) create mode 100644 src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts create mode 100644 src/backend/features/fuse/on-release/handle-release-callback.test.ts create mode 100644 src/backend/features/fuse/on-release/handle-release-callback.ts diff --git a/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts b/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts new file mode 100644 index 0000000000..e1f9ac5b8b --- /dev/null +++ b/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts @@ -0,0 +1,45 @@ +import { describe, it, vi, beforeEach } from 'vitest'; +import { call } from '../../../../../tests/vitest/utils.helper'; +import { ReleaseCallback } from './ReleaseCallback'; +import { right } from '../../../../context/shared/domain/Either'; +import * as openFlagsTracker from './open-flags-tracker'; +import * as handleReleaseModule from '../../../../backend/features/fuse/on-release/handle-release-callback'; +import { partialSpyOn } from '../../../../../tests/vitest/utils.helper'; + +vi.mock(import('@internxt/drive-desktop-core/build/backend')); + +describe('ReleaseCallback', () => { + const onReleaseSpy = partialSpyOn(openFlagsTracker, 'onRelease'); + const handleReleaseSpy = partialSpyOn(handleReleaseModule, 'handleReleaseCallback'); + + const container = { get: vi.fn() } as any; + const releaseCallback = new ReleaseCallback(container); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call onRelease to clean up open flags tracker', async () => { + handleReleaseSpy.mockResolvedValue(right(undefined)); + + await releaseCallback.execute('/Documents/file.pdf', 3); + + call(onReleaseSpy).toBe('/Documents/file.pdf'); + }); + + it('should delegate to handleReleaseCallback', async () => { + handleReleaseSpy.mockResolvedValue(right(undefined)); + + await releaseCallback.execute('/Documents/file.pdf', 3); + + call(handleReleaseSpy).toMatchObject({ path: '/Documents/file.pdf' }); + }); + + it('should return the result from handleReleaseCallback', async () => { + handleReleaseSpy.mockResolvedValue(right(undefined)); + + const result = await releaseCallback.execute('/Documents/file.pdf', 3); + + expect(result.isRight()).toBe(true); + }); +}); diff --git a/src/apps/drive/fuse/callbacks/ReleaseCallback.ts b/src/apps/drive/fuse/callbacks/ReleaseCallback.ts index 43a6a7946a..013ee29045 100644 --- a/src/apps/drive/fuse/callbacks/ReleaseCallback.ts +++ b/src/apps/drive/fuse/callbacks/ReleaseCallback.ts @@ -2,53 +2,45 @@ import { Container } from 'diod'; import { TemporalFileByPathFinder } from '../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFileUploader } from '../../../../context/storage/TemporalFiles/application/upload/TemporalFileUploader'; import { NotifyFuseCallback } from './FuseCallback'; -import { FuseIOError } from './FuseErrors'; -import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { FuseError } from './FuseErrors'; import { TemporalFileDeleter } from '../../../../context/storage/TemporalFiles/application/deletion/TemporalFileDeleter'; import { onRelease } from './open-flags-tracker'; - +import { Either } from '../../../../context/shared/domain/Either'; +import { handleReleaseCallback } from '../../../../backend/features/fuse/on-release/handle-release-callback'; + +/** + * FUSE release callback: + * called when the last file descriptor for an open file is closed. + * This is the counterpart to OpenCallback. For every open() there will eventually be a release(). + * + * If the user accesses a file on the virtual drive triggers this lifecycle: + * open (OpenCallback) → read/write (ReadCallback) → release (ReleaseCallback) + * + * to read more about this: + * https://libfuse.github.io/doxygen/structfuse__operations.html#abac8718cdfc1ee273a44831a27393419 + * + * @example `md5sum file.mp4`, `vlc video.mp4`, or Nautilus previewing a file + * will all trigger a release once the program finishes reading and closes the file descriptor. + */ export class ReleaseCallback extends NotifyFuseCallback { constructor(private readonly container: Container) { super('Release', { debug: false }); } - async execute(path: string, _fd: number) { + /** + * @param path - the virtual drive path of the file being released + * @param _fileDescriptor - a number assigned by the OS kernel when the file was opened, + * used to track which open file instance this release corresponds to (e.g. fd = 3). + * The same fd flows through open → read → release. Currently unused — we identify files by path instead. + */ + async execute(path: string, _fileDescriptor: number): Promise> { onRelease(path); - try { - const document = await this.findDocument(path); - if (document) { - return await this.handleDocument(document, path); - } - - this.logDebugMessage(`File with ${path} not found`); - return this.right(); - } catch (err: unknown) { - logger.error({ msg: 'Error in ReleaseCallback', error: err }); - return this.left(new FuseIOError('An unexpected error occurred during file release.')); - } - } - - private async findDocument(path: string) { - return this.container.get(TemporalFileByPathFinder).run(path); - } - - private async handleDocument(document: any, path: string) { - this.logDebugMessage('Offline File found'); - if (document.isAuxiliary()) return this.right(); - - return await this.uploadDocument(document, path); - } - - private async uploadDocument(document: any, path: string) { - try { - await this.container.get(TemporalFileUploader).run(document.path.value); - this.logDebugMessage('File has been uploaded'); - return this.right(); - } catch (uploadError) { - logger.error({ msg: 'Upload failed:', error: uploadError }); - await this.container.get(TemporalFileDeleter).run(path); - return this.left(new FuseIOError('Upload failed due to insufficient storage or network issues.')); - } + return await handleReleaseCallback({ + path, + findTemporalFile: (p) => this.container.get(TemporalFileByPathFinder).run(p), + uploadTemporalFile: (p) => this.container.get(TemporalFileUploader).run(p), + deleteTemporalFile: (p) => this.container.get(TemporalFileDeleter).run(p), + }); } } diff --git a/src/backend/features/fuse/on-release/handle-release-callback.test.ts b/src/backend/features/fuse/on-release/handle-release-callback.test.ts new file mode 100644 index 0000000000..7f3e4c2538 --- /dev/null +++ b/src/backend/features/fuse/on-release/handle-release-callback.test.ts @@ -0,0 +1,110 @@ +import { describe, it, vi, beforeEach } from 'vitest'; +import { call, calls } from '../../../../../tests/vitest/utils.helper'; +import { handleReleaseCallback } from './handle-release-callback'; +import { TemporalFile } from '../../../../context/storage/TemporalFiles/domain/TemporalFile'; + +vi.mock(import('@internxt/drive-desktop-core/build/backend')); + +function createTemporalFile(path: string): TemporalFile { + return TemporalFile.from({ + path, + size: 100, + createdAt: new Date(), + modifiedAt: new Date(), + }); +} + +function createAuxiliaryFile(path: string): TemporalFile { + return TemporalFile.from({ + path, + size: 0, + createdAt: new Date(), + modifiedAt: new Date(), + }); +} + +describe('handle-release-callback', () => { + const findTemporalFile = vi.fn<(path: string) => Promise>(); + const uploadTemporalFile = vi.fn<(path: string) => Promise>(); + const deleteTemporalFile = vi.fn<(path: string) => Promise>(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return right when no temporal file is found', async () => { + findTemporalFile.mockResolvedValue(undefined); + + const result = await handleReleaseCallback({ + path: '/Documents/file.pdf', + findTemporalFile, + uploadTemporalFile, + deleteTemporalFile, + }); + + expect(result.isRight()).toBe(true); + calls(findTemporalFile).toHaveLength(1); + calls(uploadTemporalFile).toHaveLength(0); + }); + + it('should skip upload for auxiliary files', async () => { + findTemporalFile.mockResolvedValue(createAuxiliaryFile('/Documents/.~lock.file.odt#')); + + const result = await handleReleaseCallback({ + path: '/Documents/.~lock.file.odt#', + findTemporalFile, + uploadTemporalFile, + deleteTemporalFile, + }); + + expect(result.isRight()).toBe(true); + calls(findTemporalFile).toHaveLength(1); + calls(uploadTemporalFile).toHaveLength(0); + }); + + it('should upload temporal file and returns right on success', async () => { + findTemporalFile.mockResolvedValue(createTemporalFile('/Documents/report.pdf')); + uploadTemporalFile.mockResolvedValue('contents-id-123'); + + const result = await handleReleaseCallback({ + path: '/Documents/report.pdf', + findTemporalFile, + uploadTemporalFile, + deleteTemporalFile, + }); + + expect(result.isRight()).toBe(true); + calls(findTemporalFile).toHaveLength(1); + call(uploadTemporalFile).toBe('/Documents/report.pdf'); + }); + + it('should delete temporal file and return left when upload fails', async () => { + findTemporalFile.mockResolvedValue(createTemporalFile('/Documents/report.pdf')); + uploadTemporalFile.mockRejectedValue(new Error('Network error')); + + const result = await handleReleaseCallback({ + path: '/Documents/report.pdf', + findTemporalFile, + uploadTemporalFile, + deleteTemporalFile, + }); + + expect(result.isLeft()).toBe(true); + call(deleteTemporalFile).toBe('/Documents/report.pdf'); + }); + + it('should return left when findTemporalFile throws an error', async () => { + findTemporalFile.mockRejectedValue(new Error('DB error')); + + const result = await handleReleaseCallback({ + path: '/Documents/report.pdf', + findTemporalFile, + uploadTemporalFile, + deleteTemporalFile, + }); + + expect(result.isLeft()).toBe(true); + calls(uploadTemporalFile).toHaveLength(0); + calls(deleteTemporalFile).toHaveLength(0); + }); +}); diff --git a/src/backend/features/fuse/on-release/handle-release-callback.ts b/src/backend/features/fuse/on-release/handle-release-callback.ts new file mode 100644 index 0000000000..61725ef5df --- /dev/null +++ b/src/backend/features/fuse/on-release/handle-release-callback.ts @@ -0,0 +1,44 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { left, right, type Either } from '../../../../context/shared/domain/Either'; +import { type TemporalFile } from '../../../../context/storage/TemporalFiles/domain/TemporalFile'; +import { FuseError, FuseIOError } from '../../../../apps/drive/fuse/callbacks/FuseErrors'; + +type Props = { + path: string; + findTemporalFile: (path: string) => Promise; + uploadTemporalFile: (path: string) => Promise; + deleteTemporalFile: (path: string) => Promise; +}; +export async function handleReleaseCallback({ + path, + findTemporalFile, + uploadTemporalFile, + deleteTemporalFile, +}: Props): Promise> { + try { + const temporalFile = await findTemporalFile(path); + + if (!temporalFile) { + logger.debug({ msg: '[Release] No temporal file found, nothing to upload', path }); + return right(undefined); + } + + if (temporalFile.isAuxiliary()) { + logger.debug({ msg: '[Release] Auxiliary file detected, skipping upload', path }); + return right(undefined); + } + + try { + await uploadTemporalFile(temporalFile.path.value); + logger.debug({ msg: '[Release] Temporal file uploaded', path }); + return right(undefined); + } catch (uploadError) { + logger.error({ msg: '[Release] Upload failed, deleting temporal file', error: uploadError, path }); + await deleteTemporalFile(path); + return left(new FuseIOError('Upload failed due to insufficient storage or network issues.')); + } + } catch (err: unknown) { + logger.error({ msg: '[Release] Unexpected error', error: err, path }); + return left(new FuseIOError('An unexpected error occurred during file release.')); + } +} From db4d8fcc1f5b22ee17a5c1267416e69fe2873181 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 23 Mar 2026 18:19:33 +0100 Subject: [PATCH 3/4] fix: comments pr --- src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts | 7 +------ .../fuse/on-release/handle-release-callback.test.ts | 6 +----- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts b/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts index e1f9ac5b8b..bcc7e793b8 100644 --- a/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts +++ b/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts @@ -1,4 +1,4 @@ -import { describe, it, vi, beforeEach } from 'vitest'; +import { describe, it, vi } from 'vitest'; import { call } from '../../../../../tests/vitest/utils.helper'; import { ReleaseCallback } from './ReleaseCallback'; import { right } from '../../../../context/shared/domain/Either'; @@ -14,11 +14,6 @@ describe('ReleaseCallback', () => { const container = { get: vi.fn() } as any; const releaseCallback = new ReleaseCallback(container); - - beforeEach(() => { - vi.clearAllMocks(); - }); - it('should call onRelease to clean up open flags tracker', async () => { handleReleaseSpy.mockResolvedValue(right(undefined)); diff --git a/src/backend/features/fuse/on-release/handle-release-callback.test.ts b/src/backend/features/fuse/on-release/handle-release-callback.test.ts index 7f3e4c2538..b0d25b8090 100644 --- a/src/backend/features/fuse/on-release/handle-release-callback.test.ts +++ b/src/backend/features/fuse/on-release/handle-release-callback.test.ts @@ -1,4 +1,4 @@ -import { describe, it, vi, beforeEach } from 'vitest'; +import { describe, it, vi } from 'vitest'; import { call, calls } from '../../../../../tests/vitest/utils.helper'; import { handleReleaseCallback } from './handle-release-callback'; import { TemporalFile } from '../../../../context/storage/TemporalFiles/domain/TemporalFile'; @@ -28,10 +28,6 @@ describe('handle-release-callback', () => { const uploadTemporalFile = vi.fn<(path: string) => Promise>(); const deleteTemporalFile = vi.fn<(path: string) => Promise>(); - beforeEach(() => { - vi.clearAllMocks(); - }); - it('should return right when no temporal file is found', async () => { findTemporalFile.mockResolvedValue(undefined); From c906160e3f6b37f92aea4cdf749da4888b2b068c Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Mon, 23 Mar 2026 19:05:17 +0100 Subject: [PATCH 4/4] fix: format --- src/core/electron/paths.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index b80c048a1d..a1b1056d0c 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -18,5 +18,5 @@ export const PATHS = { THUMBNAILS_FOLDER, TEMPORAL_FOLDER, INTERNXT_DRIVE_TMP, - DOWNLOADED + DOWNLOADED, };