Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();

Expand Down
40 changes: 40 additions & 0 deletions src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, vi } 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';

Check failure on line 5 in src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts

View workflow job for this annotation

GitHub Actions / 🧪 Test Main Process

src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts

Error: Cannot find module './open-flags-tracker' imported from '/home/runner/work/drive-desktop-linux/drive-desktop-linux/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts' ❯ src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts:5:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Caused by: Error: Failed to load url ./open-flags-tracker (resolved id: ./open-flags-tracker) in /home/runner/work/drive-desktop-linux/drive-desktop-linux/src/apps/drive/fuse/callbacks/ReleaseCallback.test.ts. Does the file exist? ❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-D4NMHUTW.js:35729:17
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);
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);
});
});
70 changes: 31 additions & 39 deletions src/apps/drive/fuse/callbacks/ReleaseCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '../../../../backend/features/fuse/on-open/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<Either<FuseError, undefined>> {
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),
});
}
}
106 changes: 106 additions & 0 deletions src/backend/features/fuse/on-release/handle-release-callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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';

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<TemporalFile | undefined>>();
const uploadTemporalFile = vi.fn<(path: string) => Promise<string>>();
const deleteTemporalFile = vi.fn<(path: string) => Promise<void>>();

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);
});
});
44 changes: 44 additions & 0 deletions src/backend/features/fuse/on-release/handle-release-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { logger } from '@internxt/drive-desktop-core/build/backend';
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the behaviour of this handler is the same as the previous code inside ReleaseCallback, the only difference is that gives more context about what is really happening and providing better naming (For example const temporalFile = await findTemporalFile(path); instead of const document = await this.findDocument(path); at a first glance it is more explicit about the intention

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<TemporalFile | undefined>;
uploadTemporalFile: (path: string) => Promise<string>;
deleteTemporalFile: (path: string) => Promise<void>;
};
export async function handleReleaseCallback({
path,
findTemporalFile,
uploadTemporalFile,
deleteTemporalFile,
}: Props): Promise<Either<FuseError, undefined>> {
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.'));
}
}
11 changes: 10 additions & 1 deletion src/context/storage/TemporalFiles/domain/TemporalFile.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { AggregateRoot } from '../../../shared/domain/AggregateRoot';
import { TemporalFilePath } from './TemporalFilePath';
import { TemporalFileSize } from './TemporalFileSize';

export type TemporalFileAttributes = {
createdAt: Date;
modifiedAt: Date;
path: string;
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.';
Expand Down
4 changes: 4 additions & 0 deletions src/core/electron/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ 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');
const DOWNLOADED = join(INTERNXT, 'downloaded');

export const PATHS = {
HOME_FOLDER_PATH,
INTERNXT,
LOGS,
THUMBNAILS_FOLDER,
TEMPORAL_FOLDER,
INTERNXT_DRIVE_TMP,
DOWNLOADED,
};
Loading