Exemplo n.º 1
0
async def fuse_mountpoint_runner(
    user_fs: UserFS,
    workspace_fs: WorkspaceFS,
    base_mountpoint_path: PurePath,
    config: dict,
    event_bus: EventBus,
):
    """
    Raises:
        MountpointDriverCrash
    """
    fuse_thread_started = threading.Event()
    fuse_thread_stopped = threading.Event()
    trio_token = trio.lowlevel.current_trio_token()
    fs_access = ThreadFSAccess(trio_token, workspace_fs, event_bus)

    mountpoint_path, initial_st_dev = await _bootstrap_mountpoint(
        base_mountpoint_path, workspace_fs)

    # Prepare event information
    event_kwargs = {
        "mountpoint": mountpoint_path,
        "workspace_id": workspace_fs.workspace_id,
        "timestamp": getattr(workspace_fs, "timestamp", None),
    }

    fuse_operations = FuseOperations(fs_access, **event_kwargs)

    try:
        teardown_cancel_scope = None
        event_bus.send(CoreEvent.MOUNTPOINT_STARTING, **event_kwargs)

        async with trio.open_service_nursery() as nursery:

            # Let fusepy decode the paths using the current file system encoding
            # Note that this does not prevent the user from using a certain encoding
            # in the context of the parsec app and another encoding in the context of
            # an application accessing the mountpoint. In this case, an encoding error
            # might be raised while fuspy tries to decode the path. If that happens,
            # fuspy will log the error and simply return EINVAL, which is acceptable.
            encoding = sys.getfilesystemencoding()

            def _run_fuse_thread():
                with importlib_resources.files(resources_module).joinpath(
                        "parsec.icns") as parsec_icns_path:

                    fuse_platform_options = {}
                    if sys.platform == "darwin":
                        fuse_platform_options = {
                            "local": True,
                            "defer_permissions": True,
                            "volname": workspace_fs.get_workspace_name(),
                            "volicon": str(parsec_icns_path.absolute()),
                        }
                        # osxfuse-specific options :
                        # local: allows mountpoint to show up correctly in finder (+ desktop)
                        # volname: specify volume name (default is OSXFUSE [...])
                        # volicon: specify volume icon (default is macOS drive icon)

                    # On defer_permissions: "The defer_permissions option [...] causes macFUSE to assume that all
                    # accesses are allowed; it will forward all operations to the file system, and it is up to
                    # somebody else to eventually allow or deny the operations." See
                    # https://github.com/osxfuse/osxfuse/wiki/Mount-options#default_permissions-and-defer_permissions
                    # This option is necessary on MacOS to give the right permissions to files inside FUSE drives,
                    # otherwise it impedes upon saving and auto-saving from Apple softwares (TextEdit, Preview...)
                    # due to the gid of files seemingly not having writing rights from the software perspective.
                    # One other solution found for this issue was to change the gid of the mountpoint and its files
                    # from staff (default) to wheel (admin gid). Checking defer_permissions allows to ignore the gid
                    # issue entirely and lets Parsec itself handle read/write rights inside workspaces.

                    else:
                        fuse_platform_options = {"auto_unmount": True}

                    logger.info("Starting fuse thread...",
                                mountpoint=mountpoint_path)
                    try:
                        # Do not let fuse start if the runner is stopping
                        # It's important that `fuse_thread_started` is set before the check
                        # in order to avoid race conditions
                        fuse_thread_started.set()
                        if teardown_cancel_scope is not None:
                            return
                        FUSE(
                            fuse_operations,
                            str(mountpoint_path.absolute()),
                            foreground=True,
                            encoding=encoding,
                            **fuse_platform_options,
                            **config,
                        )

                    except Exception as exc:
                        try:
                            errcode = errno.errorcode[exc.args[0]]
                        except (KeyError, IndexError):
                            errcode = f"Unknown error code: {exc}"
                        raise MountpointDriverCrash(
                            f"Fuse has crashed on {mountpoint_path}: {errcode}"
                        ) from exc

                    finally:
                        fuse_thread_stopped.set()

            # We're about to call the `fuse_main_real` function from libfuse, so let's make sure
            # the signals are correctly patched before that (`_path_signals` is idempotent)
            _patch_signals()

            nursery.start_soon(lambda: trio.to_thread.run_sync(
                _run_fuse_thread, cancellable=True))
            await _wait_for_fuse_ready(mountpoint_path, fuse_thread_started,
                                       initial_st_dev)

            # Indicate the mountpoint is now started
            yield mountpoint_path

    finally:
        event_bus.send(CoreEvent.MOUNTPOINT_STOPPING, **event_kwargs)
        with trio.CancelScope(shield=True) as teardown_cancel_scope:
            await _stop_fuse_thread(mountpoint_path, fuse_operations,
                                    fuse_thread_started, fuse_thread_stopped)
            await _teardown_mountpoint(mountpoint_path)
Exemplo n.º 2
0
async def winfsp_mountpoint_runner(
    user_fs: UserFS,
    workspace_fs: WorkspaceFS,
    base_mountpoint_path: PurePath,
    config: dict,
    event_bus: EventBus,
):
    """
    Raises:
        MountpointDriverCrash
    """
    device = workspace_fs.device
    workspace_name = winify_entry_name(workspace_fs.get_workspace_name())
    trio_token = trio.lowlevel.current_trio_token()
    fs_access = ThreadFSAccess(trio_token, workspace_fs, event_bus)

    user_manifest = user_fs.get_user_manifest()
    workspace_ids = [entry.id for entry in user_manifest.workspaces]
    workspace_index = workspace_ids.index(workspace_fs.workspace_id)
    # `base_mountpoint_path` is ignored given we only mount from a drive
    mountpoint_path = await _get_available_drive(workspace_index,
                                                 len(workspace_ids))

    # Prepare event information
    event_kwargs = {
        "mountpoint": mountpoint_path,
        "workspace_id": workspace_fs.workspace_id,
        "timestamp": getattr(workspace_fs, "timestamp", None),
    }

    if config.get("debug", False):
        enable_debug_log()

    # Volume label is limited to 32 WCHAR characters, so force the label to
    # ascii to easily enforce the size.
    volume_label = (unicodedata.normalize(
        "NFKD", f"{workspace_name.capitalize()}").encode(
            "ascii", "ignore")[:32].decode("ascii"))
    volume_serial_number = _generate_volume_serial_number(
        device, workspace_fs.workspace_id)
    operations = WinFSPOperations(fs_access=fs_access,
                                  volume_label=volume_label,
                                  **event_kwargs)
    # See https://docs.microsoft.com/en-us/windows/desktop/api/fileapi/nf-fileapi-getvolumeinformationa  # noqa
    fs = FileSystem(
        mountpoint_path.drive,
        operations,
        sector_size=512,
        sectors_per_allocation_unit=1,
        volume_creation_time=filetime_now(),
        volume_serial_number=volume_serial_number,
        file_info_timeout=1000,
        case_sensitive_search=1,
        case_preserved_names=1,
        unicode_on_disk=1,
        persistent_acls=0,
        reparse_points=0,
        reparse_points_access_check=0,
        named_streams=0,
        read_only_volume=workspace_fs.is_read_only(),
        post_cleanup_when_modified_only=1,
        device_control=0,
        um_file_context_is_user_context2=1,
        file_system_name="Parsec",
        prefix="",
        # The minimum value for IRP timeout is 1 minute (default is 5)
        irp_timeout=60000,
        # security_timeout_valid=1,
        # security_timeout=10000,
    )

    try:
        event_bus.send(CoreEvent.MOUNTPOINT_STARTING, **event_kwargs)

        # Manage drive icon
        drive_letter, *_ = mountpoint_path.drive
        with parsec_drive_icon_context(drive_letter):

            # Run fs start in a thread
            await trio.to_thread.run_sync(fs.start)

            # The system is too sensitive right after starting
            await trio.sleep(0.010)  # 10 ms

            # Make sure the mountpoint is ready
            await _wait_for_winfsp_ready(mountpoint_path)

            # Notify the manager that the mountpoint is ready
            yield mountpoint_path

            # Start recording `sharing.updated` events
            with event_bus.waiter_on(CoreEvent.SHARING_UPDATED) as waiter:

                # Loop over `sharing.updated` event
                while True:

                    # Restart the mountpoint with the right read_only flag if necessary
                    # Don't bother with restarting if the workspace has been revoked
                    # It's the manager's responsibility to unmount the workspace in this case
                    if (workspace_fs.is_read_only() !=
                            fs.volume_params["read_only_volume"]
                            and not workspace_fs.is_revoked()):
                        restart = partial(
                            fs.restart,
                            read_only_volume=workspace_fs.is_read_only())
                        await trio.to_thread.run_sync(restart)

                    # Wait and reset waiter
                    await waiter.wait()
                    waiter.clear()

    except Exception as exc:
        raise MountpointDriverCrash(
            f"WinFSP has crashed on {mountpoint_path}: {exc}") from exc

    finally:
        event_bus.send(CoreEvent.MOUNTPOINT_STOPPING, **event_kwargs)

        # Must run in thread given this call will wait for any winfsp operation
        # to finish so blocking the trio loop can produce a dead lock...
        with trio.CancelScope(shield=True):
            try:
                await trio.to_thread.run_sync(fs.stop)
            # The file system might not be started,
            # if the task gets cancelled before running `fs.start` for instance
            except FileSystemNotStarted:
                pass
Exemplo n.º 3
0
async def winfsp_mountpoint_runner(
    workspace_fs,
    base_mountpoint_path: Path,
    config: dict,
    event_bus,
    *,
    task_status=trio.TASK_STATUS_IGNORED,
):
    """
    Raises:
        MountpointDriverCrash
    """
    device = workspace_fs.device
    workspace_name = winify_entry_name(workspace_fs.get_workspace_name())
    trio_token = trio.hazmat.current_trio_token()
    fs_access = ThreadFSAccess(trio_token, workspace_fs)

    mountpoint_path = await _bootstrap_mountpoint(base_mountpoint_path, workspace_name)

    if config.get("debug", False):
        enable_debug_log()

    # Volume label is limited to 32 WCHAR characters, so force the label to
    # ascii to easily enforce the size.
    volume_label = (
        unicodedata.normalize("NFKD", f"{device.user_id}-{workspace_name}")
        .encode("ascii", "ignore")[:32]
        .decode("ascii")
    )
    volume_serial_number = _generate_volume_serial_number(device, workspace_fs.workspace_id)
    operations = WinFSPOperations(event_bus, volume_label, fs_access)
    # See https://docs.microsoft.com/en-us/windows/desktop/api/fileapi/nf-fileapi-getvolumeinformationa  # noqa
    fs = FileSystem(
        str(mountpoint_path.absolute()),
        operations,
        sector_size=512,
        sectors_per_allocation_unit=1,
        volume_creation_time=filetime_now(),
        volume_serial_number=volume_serial_number,
        file_info_timeout=1000,
        case_sensitive_search=1,
        case_preserved_names=1,
        unicode_on_disk=1,
        persistent_acls=0,
        reparse_points=0,
        reparse_points_access_check=0,
        named_streams=0,
        read_only_volume=0,
        post_cleanup_when_modified_only=1,
        device_control=0,
        um_file_context_is_user_context2=1,
        file_system_name="Parsec",
        prefix="",
        # The minimum value for IRP timeout is 1 minute (default is 5)
        irp_timeout=60000,
        # Work around the avast/winfsp incompatibility
        reject_irp_prior_to_transact0=True,
        # security_timeout_valid=1,
        # security_timeout=10000,
    )
    try:
        event_bus.send("mountpoint.starting", mountpoint=mountpoint_path)

        # Run fs start in a thread, as a cancellable operation
        # This is because fs.start() might get stuck for while in case of an IRP timeout
        await trio.to_thread.run_sync(fs.start, cancellable=True)

        # Because of reject_irp_prior_to_transact0, the mountpoint isn't ready yet
        # We have to add a bit of delay here, the tests would fail otherwise
        # 10 ms is more than enough, although a strict process would be nicer
        # Still, this is only temporary as avast is working on a fix at the moment
        # Another way to address this problem would be to migrate to python 3.8,
        # then use `os.stat` to differentiate between a started and a non-started
        # file syste.
        await trio.sleep(0.01)

        event_bus.send("mountpoint.started", mountpoint=mountpoint_path)
        task_status.started(mountpoint_path)

        await trio.sleep_forever()

    except Exception as exc:
        raise MountpointDriverCrash(f"WinFSP has crashed on {mountpoint_path}: {exc}") from exc

    finally:
        # Must run in thread given this call will wait for any winfsp operation
        # to finish so blocking the trio loop can produce a dead lock...
        with trio.CancelScope(shield=True):
            await trio.to_thread.run_sync(fs.stop)
        event_bus.send("mountpoint.stopped", mountpoint=mountpoint_path)
Exemplo n.º 4
0
async def fuse_mountpoint_runner(
    workspace_fs,
    base_mountpoint_path: PurePath,
    config: dict,
    event_bus,
    *,
    task_status=trio.TASK_STATUS_IGNORED,
):
    """
    Raises:
        MountpointDriverCrash
    """
    fuse_thread_started = threading.Event()
    fuse_thread_stopped = threading.Event()
    trio_token = trio.hazmat.current_trio_token()
    fs_access = ThreadFSAccess(trio_token, workspace_fs)
    fuse_operations = FuseOperations(event_bus, fs_access)

    mountpoint_path, initial_st_dev = await _bootstrap_mountpoint(
        base_mountpoint_path, workspace_fs)

    try:
        event_bus.send("mountpoint.starting", mountpoint=mountpoint_path)

        async with trio.open_service_nursery() as nursery:

            # Let fusepy decode the paths using the current file system encoding
            # Note that this does not prevent the user from using a certain encoding
            # in the context of the parsec app and another encoding in the context of
            # an application accessing the mountpoint. In this case, an encoding error
            # might be raised while fuspy tries to decode the path. If that happends,
            # fuspy will log the error and simply return EINVAL, which is acceptable.
            encoding = sys.getfilesystemencoding()

            def _run_fuse_thread():
                logger.info("Starting fuse thread...",
                            mountpoint=mountpoint_path)
                try:
                    fuse_thread_started.set()
                    FUSE(
                        fuse_operations,
                        str(mountpoint_path.absolute()),
                        foreground=True,
                        auto_unmount=True,
                        encoding=encoding,
                        **config,
                    )

                except Exception as exc:
                    try:
                        errcode = errno.errorcode[exc.args[0]]
                    except (KeyError, IndexError):
                        errcode = f"Unknown error code: {exc}"
                    raise MountpointDriverCrash(
                        f"Fuse has crashed on {mountpoint_path}: {errcode}"
                    ) from exc

                finally:
                    fuse_thread_stopped.set()

            # The fusepy runner (FUSE) relies on the `fuse_main_real` function from libfuse
            # This function is high-level helper on top of the libfuse API that is intended
            # for simple application. As such, it sets some signal handlers to exit cleanly
            # after a SIGTINT, a SIGTERM or a SIGHUP. This is, however, not compatible with
            # our multi-instance multi-threaded application. A simple workaround here is to
            # restore the signals to their previous state once the fuse instance is started.
            with _reset_signals():
                nursery.start_soon(lambda: trio.to_thread.run_sync(
                    _run_fuse_thread, cancellable=True))
                await _wait_for_fuse_ready(mountpoint_path,
                                           fuse_thread_started, initial_st_dev)

            event_bus.send("mountpoint.started", mountpoint=mountpoint_path)
            task_status.started(mountpoint_path)

    finally:
        await _stop_fuse_thread(mountpoint_path, fuse_operations,
                                fuse_thread_stopped)
        event_bus.send("mountpoint.stopped", mountpoint=mountpoint_path)
        await _teardown_mountpoint(mountpoint_path)
Exemplo n.º 5
0
async def fuse_mountpoint_runner(
    user_fs,
    workspace_fs,
    base_mountpoint_path: PurePath,
    config: dict,
    event_bus,
    *,
    task_status=trio.TASK_STATUS_IGNORED,
):
    """
    Raises:
        MountpointDriverCrash
    """
    fuse_thread_started = threading.Event()
    fuse_thread_stopped = threading.Event()
    trio_token = trio.lowlevel.current_trio_token()
    fs_access = ThreadFSAccess(trio_token, workspace_fs)
    fuse_operations = FuseOperations(event_bus, fs_access)

    mountpoint_path, initial_st_dev = await _bootstrap_mountpoint(
        base_mountpoint_path, workspace_fs)

    # Prepare event information
    event_kwargs = {
        "mountpoint": mountpoint_path,
        "workspace_id": workspace_fs.workspace_id,
        "timestamp": getattr(workspace_fs, "timestamp", None),
    }
    try:
        teardown_cancel_scope = None
        event_bus.send(CoreEvent.MOUNTPOINT_STARTING, **event_kwargs)

        async with trio.open_service_nursery() as nursery:

            # Let fusepy decode the paths using the current file system encoding
            # Note that this does not prevent the user from using a certain encoding
            # in the context of the parsec app and another encoding in the context of
            # an application accessing the mountpoint. In this case, an encoding error
            # might be raised while fuspy tries to decode the path. If that happens,
            # fuspy will log the error and simply return EINVAL, which is acceptable.
            encoding = sys.getfilesystemencoding()

            def _run_fuse_thread():
                fuse_platform_options = {}
                if sys.platform == "darwin":
                    fuse_platform_options = {
                        "local":
                        True,
                        "volname":
                        workspace_fs.get_workspace_name(),
                        "volicon":
                        Path(resources.__file__).absolute().parent /
                        "parsec.icns",
                    }
                    # osxfuse-specific options :
                    # - local : allows mountpoint to show up correctly in finder (+ desktop)
                    # - volname : specify volume name (default is OSXFUSE [...])
                    # - volicon : specify volume icon (default is macOS drive icon)

                else:
                    fuse_platform_options = {"auto_unmount": True}

                logger.info("Starting fuse thread...",
                            mountpoint=mountpoint_path)
                try:
                    # Do not let fuse start if the runner is stopping
                    # It's important that `fuse_thread_started` is set before the check
                    # in order to avoid race conditions
                    fuse_thread_started.set()
                    if teardown_cancel_scope is not None:
                        return
                    FUSE(
                        fuse_operations,
                        str(mountpoint_path.absolute()),
                        foreground=True,
                        encoding=encoding,
                        **fuse_platform_options,
                        **config,
                    )

                except Exception as exc:
                    try:
                        errcode = errno.errorcode[exc.args[0]]
                    except (KeyError, IndexError):
                        errcode = f"Unknown error code: {exc}"
                    raise MountpointDriverCrash(
                        f"Fuse has crashed on {mountpoint_path}: {errcode}"
                    ) from exc

                finally:
                    fuse_thread_stopped.set()

            # The fusepy runner (FUSE) relies on the `fuse_main_real` function from libfuse
            # This function is high-level helper on top of the libfuse API that is intended
            # for simple application. As such, it sets some signal handlers to exit cleanly
            # after a SIGTINT, a SIGTERM or a SIGHUP. This is, however, not compatible with
            # our multi-instance multi-threaded application. A simple workaround here is to
            # restore the signals to their previous state once the fuse instance is started.
            with _reset_signals():
                nursery.start_soon(lambda: trio.to_thread.run_sync(
                    _run_fuse_thread, cancellable=True))
                await _wait_for_fuse_ready(mountpoint_path,
                                           fuse_thread_started, initial_st_dev)

            event_bus.send(CoreEvent.MOUNTPOINT_STARTED, **event_kwargs)
            task_status.started(mountpoint_path)

    finally:
        with trio.CancelScope(shield=True) as teardown_cancel_scope:
            await _stop_fuse_thread(mountpoint_path, fuse_operations,
                                    fuse_thread_started, fuse_thread_stopped)
            event_bus.send(CoreEvent.MOUNTPOINT_STOPPED, **event_kwargs)
            await _teardown_mountpoint(mountpoint_path)
Exemplo n.º 6
0
def get_path_and_translate_error(
    fs_access: ThreadFSAccess,
    operation: str,
    file_context: Union[OpenedFile, OpenedFolder, str],
    mountpoint: PurePath,
    workspace_id: EntryID,
    timestamp: Optional[DateTime],
) -> Iterator[FsPath]:
    path: FsPath = FsPath("/<unkonwn>")
    try:
        if isinstance(file_context, (OpenedFile, OpenedFolder)):
            path = file_context.path
        else:
            # FsPath conversion might raise an FSNameTooLongError so make
            # sure it runs within the try-except so it can be caught by the
            # FSLocalOperationError filter.
            path = _winpath_to_parsec(file_context)
        yield path

    except NTStatusError:
        raise

    except FSLocalOperationError as exc:
        raise NTStatusError(exc.ntstatus) from exc

    except FSRemoteOperationError as exc:
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_REMOTE_ERROR,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        raise NTStatusError(exc.ntstatus) from exc

    except (Cancelled, RunFinishedError) as exc:
        # WinFSP teardown operation doesn't make sure no concurrent operation
        # are running
        raise NTStatusError(NTSTATUS.STATUS_NO_SUCH_DEVICE) from exc

    except TrioDealockTimeoutError as exc:
        # See the similar clause in `fuse_operations` for a detailed explanation
        logger.error(
            "The trio thread is unreachable, a deadlock might have occured",
            operation=operation,
            path=str(path),
            mountpoint=str(mountpoint),
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_TRIO_DEADLOCK_ERROR,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        raise NTStatusError(NTSTATUS.STATUS_INTERNAL_ERROR) from exc

    except Exception as exc:
        logger.exception(
            "Unhandled exception in winfsp mountpoint",
            operation=operation,
            path=str(path),
            mountpoint=str(mountpoint),
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_UNHANDLED_ERROR,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        raise NTStatusError(NTSTATUS.STATUS_INTERNAL_ERROR) from exc
Exemplo n.º 7
0
def get_path_and_translate_error(
    fs_access: ThreadFSAccess,
    operation: str,
    context: Optional[str],
    mountpoint: PurePath,
    workspace_id: EntryID,
    timestamp: Optional[DateTime],
) -> Iterator[FsPath]:
    path: FsPath = FsPath("/<unknown>")
    try:
        # The context argument might be None or "-" in some special cases
        # related to `release` and `releasedir` (when the file descriptor
        # is available but the corresponding path is not). In those cases,
        # we can simply ignore the path.
        if context not in (None, "-"):
            # FsPath conversion might raise an FSNameTooLongError so make
            # sure it runs within the try-except so it can be caught by the
            # FSLocalOperationError filter.
            path = FsPath(context)
        yield path

    except FuseOSError:
        raise

    except FSReadOnlyError as exc:
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_READONLY,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        raise FuseOSError(exc.errno) from exc

    except FSLocalOperationError as exc:
        raise FuseOSError(exc.errno) from exc

    except FSRemoteOperationError as exc:
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_REMOTE_ERROR,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        raise FuseOSError(exc.errno) from exc

    except TrioDealockTimeoutError as exc:
        # This exception is raised when the trio thread cannot be reached.
        # This is likely due to a deadlock, i.e the trio thread performing
        # a synchronous call to access the fuse file system (this can easily
        # happen in a third party library, e.g `QDesktopServices::openUrl`).
        # The fuse/winfsp kernel driver being involved in the deadlock, the
        # effects can easily propagate to the file explorer or the system
        # in general. This is why it is much better to break out of it with
        # and to return an error code indicating that the operation failed.
        logger.error(
            "The trio thread is unreachable, a deadlock might have occured",
            operation=operation,
            path=str(path),
            mountpoint=str(mountpoint),
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_TRIO_DEADLOCK_ERROR,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        # Use EINVAL as error code, so it behaves the same as internal errors
        raise FuseOSError(errno.EINVAL) from exc

    except Exception as exc:
        logger.exception(
            "Unhandled exception in fuse mountpoint",
            operation=operation,
            path=str(path),
            mountpoint=str(mountpoint),
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        fs_access.send_event(
            CoreEvent.MOUNTPOINT_UNHANDLED_ERROR,
            exc=exc,
            operation=operation,
            path=path,
            mountpoint=mountpoint,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )
        # Use EINVAL as fallback error code, since this is what fusepy does.
        raise FuseOSError(errno.EINVAL) from exc

    except trio.Cancelled as exc:  # Cancelled inherits BaseException
        # Might be raised by `self.fs_access` if the trio loop finishes in our back
        raise FuseOSError(errno.EACCES) from exc