diff --git a/src/apps/drive/fuse/FuseApp.test.ts b/src/apps/drive/fuse/FuseApp.test.ts new file mode 100644 index 0000000000..01a42f2cbb --- /dev/null +++ b/src/apps/drive/fuse/FuseApp.test.ts @@ -0,0 +1,263 @@ +import { Container } from 'diod'; +import { FuseApp } from './FuseApp'; +import { VirtualDrive } from '../virtual-drive/VirtualDrive'; +import { StorageClearer } from '../../../context/storage/StorageFiles/application/delete/StorageClearer'; +import { FileRepositorySynchronizer } from '../../../context/virtual-drive/files/application/FileRepositorySynchronizer'; +import { FolderRepositorySynchronizer } from '../../../context/virtual-drive/folders/application/FolderRepositorySynchronizer/FolderRepositorySynchronizer'; +import { RemoteTreeBuilder } from '../../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder'; +import { StorageRemoteChangesSyncher } from '../../../context/storage/StorageFiles/application/sync/StorageRemoteChangesSyncher'; +import * as helpersModule from './helpers'; +import * as hydrationModule from '../../../backend/features/fuse/on-read/hydration-registry'; +import * as childProcess from 'child_process'; +import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { loggerMock } from 'tests/vitest/mocks.helper'; +import { Abstract } from 'diod'; +import { ChildProcess, ExecFileException } from 'child_process'; + +type ExecFileCallback = (error: ExecFileException | null) => void; + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +const mountPromiseMock = partialSpyOn(helpersModule, 'mountPromise'); +const destroyAllHydrationsMock = partialSpyOn(hydrationModule, 'destroyAllHydrations'); +const execFileMock = vi.mocked(childProcess.execFile); + +function createMockContainer() { + const services = new Map, unknown>(); + + const register = (token: Abstract, mock: unknown) => { + services.set(token, mock); + }; + + const container = { + get: vi.fn((token: Abstract) => { + return services.get(token) ?? { run: vi.fn() }; + }), + } as unknown as Container; + + return { container, register }; +} + +function createFuseApp(container: Container) { + const virtualDrive = {} as VirtualDrive; + return new FuseApp(virtualDrive, container, '/tmp/test-mount', 1, 'root-uuid'); +} + +describe('FuseApp', () => { + let container: Container; + let register: (token: Abstract, mock: unknown) => void; + let fuseApp: FuseApp; + + beforeEach(() => { + ({ container, register } = createMockContainer()); + fuseApp = createFuseApp(container); + }); + + describe('getStatus', () => { + it('should return UNMOUNTED initially', () => { + expect(fuseApp.getStatus()).toBe('UNMOUNTED'); + }); + }); + + describe('mount', () => { + it('should return UNMOUNTED if fuse is not initialized', async () => { + const status = await fuseApp.mount(); + + expect(status).toBe('UNMOUNTED'); + expect(loggerMock.error).toBeCalledWith({ + msg: '[FUSE] Cannot mount: FUSE instance not initialized', + }); + }); + + it('should mount successfully and emit mounted event', async () => { + mountPromiseMock.mockResolvedValueOnce(undefined); + const mountedHandler = vi.fn(); + fuseApp.on('mounted', mountedHandler); + + await fuseApp.start(); + + expect(fuseApp.getStatus()).toBe('MOUNTED'); + expect(mountedHandler).toHaveBeenCalled(); + }); + + it('should return MOUNTED without remounting if already mounted', async () => { + mountPromiseMock.mockResolvedValueOnce(undefined); + await fuseApp.start(); + + const status = await fuseApp.mount(); + + expect(status).toBe('MOUNTED'); + expect(loggerMock.debug).toBeCalledWith({ + msg: '[FUSE] Already mounted', + }); + }); + + it('should set status to ERROR if mount fails', async () => { + vi.useFakeTimers(); + mountPromiseMock.mockRejectedValue(new Error('mount failed')); + + const startPromise = fuseApp.start(); + // eslint-disable-next-line no-await-in-loop + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(3000); + } + await startPromise; + + expect(fuseApp.getStatus()).toBe('ERROR'); + vi.useRealTimers(); + }); + }); + + describe('start', () => { + it('should emit mount-error after all retries fail', async () => { + vi.useFakeTimers(); + mountPromiseMock.mockRejectedValue(new Error('mount failed')); + const mountErrorHandler = vi.fn(); + fuseApp.on('mount-error', mountErrorHandler); + + const startPromise = fuseApp.start(); + // eslint-disable-next-line no-await-in-loop + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(3000); + } + await startPromise; + + expect(mountErrorHandler).toHaveBeenCalled(); + expect(loggerMock.error).toBeCalledWith({ + msg: '[FUSE] mount error after max retries', + }); + vi.useRealTimers(); + }); + + it('should call update after successful mount', async () => { + const tree = { files: [], folders: [] }; + const remoteTreeBuilder = { run: vi.fn().mockResolvedValue(tree) }; + const fileSynchronizer = { run: vi.fn().mockResolvedValue(undefined) }; + const folderSynchronizer = { run: vi.fn().mockResolvedValue(undefined) }; + const storageSyncher = { run: vi.fn().mockResolvedValue(undefined) }; + + register(RemoteTreeBuilder, remoteTreeBuilder); + register(FileRepositorySynchronizer, fileSynchronizer); + register(FolderRepositorySynchronizer, folderSynchronizer); + register(StorageRemoteChangesSyncher, storageSyncher); + + mountPromiseMock.mockResolvedValueOnce(undefined); + + await fuseApp.start(); + + expect(remoteTreeBuilder.run).toBeCalledWith(1, 'root-uuid'); + }); + }); + + describe('stop', () => { + it('should do nothing if fuse is not initialized', async () => { + await fuseApp.stop(); + + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it('should unmount fuse and reset status', async () => { + mountPromiseMock.mockResolvedValueOnce(undefined); + execFileMock.mockImplementation((_cmd, _args, ...rest) => { + const cb = rest.pop() as ExecFileCallback; + cb(null); + return {} as ChildProcess; + }); + + await fuseApp.start(); + expect(fuseApp.getStatus()).toBe('MOUNTED'); + + await fuseApp.stop(); + + expect(fuseApp.getStatus()).toBe('UNMOUNTED'); + expect(execFileMock).toBeCalledWith('/usr/bin/fusermount', ['-u', '/tmp/test-mount'], expect.any(Function)); + }); + + it('should fall back to lazy unmount when non-lazy fails', async () => { + mountPromiseMock.mockResolvedValueOnce(undefined); + + let callCount = 0; + execFileMock.mockImplementation((_cmd, _args, ...rest) => { + const cb = rest.pop() as ExecFileCallback; + callCount++; + if (callCount === 1) { + cb(new Error('device busy')); + } else { + cb(null); + } + return {} as ChildProcess; + }); + + await fuseApp.start(); + await fuseApp.stop(); + + expect(execFileMock).toHaveBeenCalledTimes(2); + expect(execFileMock).toBeCalledWith('/usr/bin/fusermount', ['-uz', '/tmp/test-mount'], expect.any(Function)); + }); + + it('should resolve even when both unmount attempts fail', async () => { + mountPromiseMock.mockResolvedValueOnce(undefined); + execFileMock.mockImplementation((_cmd, _args, ...rest) => { + const cb = rest.pop() as ExecFileCallback; + cb(new Error('unmount failed')); + return {} as ChildProcess; + }); + + await fuseApp.start(); + await fuseApp.stop(); + + expect(fuseApp.getStatus()).toBe('UNMOUNTED'); + expect(loggerMock.error).toBeCalledWith(expect.objectContaining({ msg: '[FUSE] lazy unmount failed:' })); + }); + }); + + describe('clearCache', () => { + it('should destroy hydrations and clear storage', async () => { + const storageClearer = { run: vi.fn().mockResolvedValue(undefined) }; + register(StorageClearer, storageClearer); + destroyAllHydrationsMock.mockResolvedValue(undefined); + + await fuseApp.clearCache(); + + expect(destroyAllHydrationsMock).toHaveBeenCalled(); + expect(storageClearer.run).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should build remote tree and synchronize', async () => { + const tree = { files: ['file1'], folders: ['folder1'] }; + const remoteTreeBuilder = { run: vi.fn().mockResolvedValue(tree) }; + const fileSynchronizer = { run: vi.fn().mockResolvedValue(undefined) }; + const folderSynchronizer = { run: vi.fn().mockResolvedValue(undefined) }; + const storageSyncher = { run: vi.fn().mockResolvedValue(undefined) }; + + register(RemoteTreeBuilder, remoteTreeBuilder); + register(FileRepositorySynchronizer, fileSynchronizer); + register(FolderRepositorySynchronizer, folderSynchronizer); + register(StorageRemoteChangesSyncher, storageSyncher); + + await fuseApp.update(); + + expect(remoteTreeBuilder.run).toBeCalledWith(1, 'root-uuid'); + expect(fileSynchronizer.run).toBeCalledWith(['file1']); + expect(folderSynchronizer.run).toBeCalledWith(['folder1']); + expect(storageSyncher.run).toHaveBeenCalled(); + }); + + it('should log error when tree building fails', async () => { + const error = new Error('network error'); + const remoteTreeBuilder = { run: vi.fn().mockRejectedValue(error) }; + register(RemoteTreeBuilder, remoteTreeBuilder); + + await fuseApp.update(); + + expect(loggerMock.error).toBeCalledWith({ + msg: '[FUSE] Error Updating the tree:', + error, + }); + }); + }); +}); diff --git a/src/apps/drive/fuse/FuseApp.ts b/src/apps/drive/fuse/FuseApp.ts index 0cb2e4d27b..a93aff91cc 100644 --- a/src/apps/drive/fuse/FuseApp.ts +++ b/src/apps/drive/fuse/FuseApp.ts @@ -21,6 +21,7 @@ import { TrashFolderCallback } from './callbacks/TrashFolderCallback'; import { WriteCallback } from './callbacks/WriteCallback'; import { mountPromise } from './helpers'; import { StorageRemoteChangesSyncher } from '../../../context/storage/StorageFiles/application/sync/StorageRemoteChangesSyncher'; +import { execFile } from 'node:child_process'; import { EventEmitter } from 'stream'; import Fuse from '@gcas/fuse'; @@ -76,6 +77,7 @@ export class FuseApp extends EventEmitter { this._fuse = new Fuse(this.localRoot, ops, { debug: false, force: true, + autoUnmount: true, maxRead: FuseApp.MAX_INT_32, }); @@ -90,8 +92,35 @@ export class FuseApp extends EventEmitter { } async stop() { - // It is not possible to implement this method while still using @gcas/fuse. - // For more information, see this ticket. https://inxt.atlassian.net/browse/PB-5389 + if (!this._fuse) { + return; + } + + await this.unmountFuse(); + this._fuse = undefined; + this.status = 'UNMOUNTED'; + } + + private unmountFuse(): Promise { + // It is not possible to implement this method during logout while @gcas/fuse is still in use. + // For more information, see this issue. https://inxt.atlassian.net/browse/PB-5389 + + const fusermount = '/usr/bin/fusermount'; + return new Promise((resolve) => { + execFile(fusermount, ['-u', this.localRoot], (err) => { + if (!err) { + resolve(); + return; + } + logger.debug({ msg: '[FUSE] non-lazy unmount failed, trying lazy unmount', error: err }); + execFile(fusermount, ['-uz', this.localRoot], (err2) => { + if (err2) { + logger.error({ msg: '[FUSE] lazy unmount failed:', error: err2 }); + } + resolve(); + }); + }); + }); } async clearCache(): Promise { diff --git a/src/apps/drive/hydration-api/HydrationApi.test.ts b/src/apps/drive/hydration-api/HydrationApi.test.ts new file mode 100644 index 0000000000..96f55e42af --- /dev/null +++ b/src/apps/drive/hydration-api/HydrationApi.test.ts @@ -0,0 +1,107 @@ +import { Container } from 'diod'; +import { HydrationApi } from './HydrationApi'; +import * as contentsRoute from './routes/contents'; +import * as filesRoute from './routes/files'; +import { partialSpyOn } from 'tests/vitest/utils.helper'; +import express from 'express'; +import { loggerMock } from 'tests/vitest/mocks.helper'; + +const buildHydrationRouterMock = partialSpyOn(contentsRoute, 'buildHydrationRouter'); +const buildFilesRouterMock = partialSpyOn(filesRoute, 'buildFilesRouter'); + +function createMockContainer() { + return { + get: vi.fn(() => ({ run: vi.fn() })), + } as unknown as Container; +} + +describe('HydrationApi', () => { + let container: Container; + let hydrationApi: HydrationApi; + + beforeEach(() => { + container = createMockContainer(); + + buildHydrationRouterMock.mockReturnValue(express.Router()); + buildFilesRouterMock.mockReturnValue(express.Router()); + }); + + afterEach(async () => { + await hydrationApi.stop(); + }); + + describe('start', () => { + it('should start the server and log the port', async () => { + hydrationApi = new HydrationApi(container); + + await hydrationApi.start({ debug: false, timeElapsed: false }); + + expect(loggerMock.debug).toBeCalledWith({ + msg: '[HYDRATION API] running on port 4567', + }); + }); + + it('should build hydration and files routers with the container', async () => { + hydrationApi = new HydrationApi(container); + + await hydrationApi.start({ debug: false, timeElapsed: false }); + + expect(buildHydrationRouterMock).toBeCalledWith(container); + expect(buildFilesRouterMock).toBeCalledWith(container); + }); + + it('should enable debug logging middleware when debug is true', async () => { + hydrationApi = new HydrationApi(container); + + await hydrationApi.start({ debug: true, timeElapsed: false }); + + const response = await fetch('http://localhost:4567/hydration/test'); + // The request itself may 404, but the debug middleware should have logged + expect(loggerMock.debug).toBeCalledWith( + expect.objectContaining({ + msg: expect.stringContaining('[HYDRATION API]'), + }), + ); + }); + }); + + describe('stop', () => { + it('should resolve immediately if server was never started', async () => { + hydrationApi = new HydrationApi(container); + + await expect(hydrationApi.stop()).resolves.toBeUndefined(); + }); + + it('should stop the server after it was started', async () => { + hydrationApi = new HydrationApi(container); + await hydrationApi.start({ debug: false, timeElapsed: false }); + + await expect(hydrationApi.stop()).resolves.toBeUndefined(); + }); + + it('should destroy open sockets when stopping', async () => { + hydrationApi = new HydrationApi(container); + await hydrationApi.start({ debug: false, timeElapsed: false }); + + // Create a connection to generate an open socket + const socket = new (await import('node:net')).Socket(); + await new Promise((resolve, reject) => { + socket.connect(4567, '127.0.0.1', () => resolve()); + socket.on('error', reject); + }); + + // Small delay to ensure the server registers the socket + await new Promise((resolve) => setTimeout(resolve, 50)); + + await expect(hydrationApi.stop()).resolves.toBeUndefined(); + }); + + it('should be safe to call stop multiple times', async () => { + hydrationApi = new HydrationApi(container); + await hydrationApi.start({ debug: false, timeElapsed: false }); + + await hydrationApi.stop(); + await expect(hydrationApi.stop()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/apps/drive/hydration-api/HydrationApi.ts b/src/apps/drive/hydration-api/HydrationApi.ts index 861e2187fe..e584290cba 100644 --- a/src/apps/drive/hydration-api/HydrationApi.ts +++ b/src/apps/drive/hydration-api/HydrationApi.ts @@ -16,6 +16,7 @@ export class HydrationApi { private static readonly PORT = 4567; private readonly app; private server: Server | null = null; + private readonly openSockets = new Set(); constructor(private readonly container: Container) { this.app = express(); @@ -75,20 +76,35 @@ export class HydrationApi { }); resolve(); }); + + this.server.on('connection', (socket) => { + this.openSockets.add(socket); + socket.once('close', () => this.openSockets.delete(socket)); + }); }); } async stop(): Promise { return new Promise((resolve, reject) => { - if (this.server) - this.server.close((err) => { - if (err) { - reject(err); - return; - } - - resolve(); - }); + if (!this.server) { + resolve(); + return; + } + + for (const socket of this.openSockets) { + socket.destroy(); + } + this.openSockets.clear(); + + this.server.close((err) => { + if (err) { + reject(err); + return; + } + + this.server = null; + resolve(); + }); }); } } diff --git a/src/apps/drive/index.ts b/src/apps/drive/index.ts index 91fa2e858f..c48f372b68 100644 --- a/src/apps/drive/index.ts +++ b/src/apps/drive/index.ts @@ -33,14 +33,30 @@ export async function startVirtualDrive() { } export async function stopAndClearFuseApp() { - if (!fuseApp || !hydrationApi) { - logger.debug({ msg: 'FuseApp or HydrationApi not initialized, skipping stop and clear.' }); + await stopHydrationApi(); + await stopFuseApp(); +} + +export async function updateFuseApp() { + await fuseApp.update(); +} + +export function getFuseDriveState() { + if (!fuseApp) { + return 'UNMOUNTED'; + } + return fuseApp.getStatus(); +} + +async function stopFuseApp() { + if (!fuseApp) { + logger.debug({ msg: 'FuseApp not initialized, skipping stop.' }); return; } try { + await stopHydrationApi(); logger.debug({ msg: 'Stopping and clearing FuseApp...' }); - await hydrationApi.stop(); await fuseApp.clearCache(); await fuseApp.stop(); } catch (error) { @@ -48,18 +64,16 @@ export async function stopAndClearFuseApp() { } } -export async function updateFuseApp() { - await fuseApp.update(); -} - -export function getFuseDriveState() { - if (!fuseApp) { - return 'UNMOUNTED'; +export async function stopHydrationApi() { + if (!hydrationApi) { + logger.debug({ msg: 'HydrationApi not initialized, skipping stop.' }); + return; } - return fuseApp.getStatus(); -} -export async function stopFuse() { - await fuseApp.stop(); - await hydrationApi.stop(); + try { + logger.debug({ msg: 'Stopping HydrationApi...' }); + await hydrationApi.stop(); + } catch (error) { + logger.error({ msg: 'Error stopping HydrationApi:', error }); + } } diff --git a/src/apps/main/virtual-drive.ts b/src/apps/main/virtual-drive.ts index 4fb1c2e921..830e8ce201 100644 --- a/src/apps/main/virtual-drive.ts +++ b/src/apps/main/virtual-drive.ts @@ -1,8 +1,8 @@ import { ipcMain } from 'electron'; -import { getFuseDriveState, startVirtualDrive, stopAndClearFuseApp, updateFuseApp } from '../drive'; +import { getFuseDriveState, startVirtualDrive, updateFuseApp, stopHydrationApi } from '../drive'; import eventBus from './event-bus'; -eventBus.on('USER_LOGGED_OUT', stopAndClearFuseApp); +eventBus.on('USER_LOGGED_OUT', stopHydrationApi); eventBus.on('INITIAL_SYNC_READY', startVirtualDrive); eventBus.on('REMOTE_CHANGES_SYNCHED', updateFuseApp); diff --git a/src/context/virtual-drive/files/__mocks__/RemoteFileSystemMock.ts b/src/context/virtual-drive/files/__mocks__/RemoteFileSystemMock.ts index 8835b08ec1..008fa897a0 100644 --- a/src/context/virtual-drive/files/__mocks__/RemoteFileSystemMock.ts +++ b/src/context/virtual-drive/files/__mocks__/RemoteFileSystemMock.ts @@ -1,6 +1,5 @@ import { Either } from '../../../shared/domain/Either'; import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; -import { File } from '../domain/File'; import { FileDataToPersist, PersistedFileData, RemoteFileSystem } from '../domain/file-systems/RemoteFileSystem'; export class RemoteFileSystemMock implements RemoteFileSystem { diff --git a/src/core/quit/quit.handler.test.ts b/src/core/quit/quit.handler.test.ts index 65a161870f..bba3a44a07 100644 --- a/src/core/quit/quit.handler.test.ts +++ b/src/core/quit/quit.handler.test.ts @@ -7,6 +7,7 @@ import * as registerQuitHandlerModule from './quit.handler'; describe('quit', () => { const stopAndClearFuseAppMock = partialSpyOn(driveModule, 'stopAndClearFuseApp'); const appQuitMock = partialSpyOn(app, 'quit'); + const appOnMock = partialSpyOn(app, 'on', false); const ipcMainOnMock = partialSpyOn(ipcMain, 'on', false); const registerQuitHandlerMock = partialSpyOn(registerQuitHandlerModule, 'registerQuitHandler'); @@ -21,6 +22,12 @@ describe('quit', () => { call(ipcMainOnMock).toMatchObject(['user-quit', expect.any(Function)]); }); + it('should register before-quit handler', () => { + registerQuitHandlerModule.registerQuitHandler(); + + expect(appOnMock).toBeCalledWith('before-quit', expect.any(Function)); + }); + it('should call stopAndClearFuseApp on user-quit event', async () => { registerQuitHandlerModule.registerQuitHandler(); await (ipcMainOnMock.mock.calls[0][1] as () => Promise)(); @@ -34,4 +41,37 @@ describe('quit', () => { expect(appQuitMock).toBeCalled(); }); + + it('should call cleanup and prevent default on before-quit', async () => { + registerQuitHandlerModule.registerQuitHandler(); + + const beforeQuitHandler = (appOnMock.mock.calls as unknown[][]).find(([event]) => event === 'before-quit')?.[1] as ( + event: Electron.Event, + ) => void; + + const preventDefault = vi.fn(); + beforeQuitHandler({ preventDefault } as unknown as Electron.Event); + await Promise.resolve(); + + expect(preventDefault).toBeCalled(); + expect(stopAndClearFuseAppMock).toBeCalled(); + expect(appQuitMock).toBeCalled(); + }); + + it('should not run cleanup twice when user-quit and before-quit are both triggered', async () => { + registerQuitHandlerModule.registerQuitHandler(); + + await (ipcMainOnMock.mock.calls[0][1] as () => Promise)(); + + const beforeQuitHandler = (appOnMock.mock.calls as unknown[][]).find(([event]) => event === 'before-quit')?.[1] as ( + event: Electron.Event, + ) => void; + + const preventDefault = vi.fn(); + beforeQuitHandler({ preventDefault } as unknown as Electron.Event); + await Promise.resolve(); + + expect(stopAndClearFuseAppMock).toBeCalledTimes(1); + expect(preventDefault).not.toBeCalled(); + }); }); diff --git a/src/core/quit/quit.handler.ts b/src/core/quit/quit.handler.ts index a8a930fadc..cf2931fe48 100644 --- a/src/core/quit/quit.handler.ts +++ b/src/core/quit/quit.handler.ts @@ -1,8 +1,27 @@ import { app, ipcMain } from 'electron'; import { stopAndClearFuseApp } from '../../apps/drive'; + export function registerQuitHandler() { - ipcMain.on('user-quit', async () => { + let isQuitting = false; + + const cleanupAndQuit = async () => { + if (isQuitting) { + return; + } + + isQuitting = true; await stopAndClearFuseApp(); app.quit(); + }; + + ipcMain.on('user-quit', cleanupAndQuit); + + app.on('before-quit', (event) => { + if (isQuitting) { + return; + } + + event.preventDefault(); + void cleanupAndQuit(); }); } diff --git a/vitest.setup.main.ts b/vitest.setup.main.ts index 50f0c1646b..12d60c6c66 100644 --- a/vitest.setup.main.ts +++ b/vitest.setup.main.ts @@ -8,6 +8,7 @@ vi.mock('electron', () => ({ getName: vi.fn().mockReturnValue('DriveDesktop'), getVersion: vi.fn().mockReturnValue('1.0.0'), quit: vi.fn(), + on: vi.fn(), }, ipcMain: { on: vi.fn(),