1242 lines
47 KiB
Python
1242 lines
47 KiB
Python
"""Control-commanded happy-path transfer execution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Callable, Iterable
|
|
|
|
from archive_clients.protocol import decode_message, encode_message
|
|
from archive_clients.eviction import (
|
|
EvictionError,
|
|
compensate_materialized_files,
|
|
remove_qb_entry,
|
|
safe_unlink,
|
|
verify_and_snapshot,
|
|
)
|
|
from archive_clients.qbittorrent import (
|
|
QBittorrentDownloadAttempt,
|
|
QBittorrentReader,
|
|
)
|
|
from archive_clients.resources import NormalizedResource
|
|
from archive_clients.state import ClientStore
|
|
from archive_clients.syncthing import RouteSetupError, SyncthingTransferObserver
|
|
from archive_clients.transfer import (
|
|
TransferError,
|
|
TransferIntegrityError,
|
|
cleanup_partial_transfer,
|
|
cleanup_transfer,
|
|
load_published_transfer,
|
|
materialize_transfer,
|
|
stage_transfer,
|
|
)
|
|
from archive_control.v1 import (
|
|
common_pb2,
|
|
control_pb2,
|
|
job_pb2,
|
|
resource_pb2,
|
|
transfer_pb2,
|
|
)
|
|
|
|
|
|
class JobExecutionError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class JobCancelled(JobExecutionError):
|
|
pass
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ClientJobExecutor:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
client_id: str,
|
|
qbittorrent: QBittorrentReader,
|
|
store: ClientStore,
|
|
qb_root: Path,
|
|
qb_api_root: PurePosixPath,
|
|
route_path: Callable[[str], Path],
|
|
syncthing_transport: object,
|
|
sparse_supported: bool,
|
|
poll_interval: float = 1,
|
|
verification_timeout: float = 30 * 60,
|
|
free_space_reserve_bytes: int = 32 * 1024 * 1024,
|
|
):
|
|
self.client_id = client_id
|
|
self.qbittorrent = qbittorrent
|
|
self.store = store
|
|
self.qb_root = qb_root
|
|
self.qb_api_root = qb_api_root
|
|
self.route_path = route_path
|
|
self.syncthing_transport = syncthing_transport
|
|
self.sparse_supported = sparse_supported
|
|
self.poll_interval = poll_interval
|
|
self.verification_timeout = verification_timeout
|
|
self.free_space_reserve_bytes = free_space_reserve_bytes
|
|
self._cancel_events: dict[str, threading.Event] = {}
|
|
|
|
def request_cancel(self, job_id: str) -> None:
|
|
self._cancel_events.setdefault(job_id, threading.Event()).set()
|
|
|
|
def assign(
|
|
self, command: control_pb2.AssignJobCommand
|
|
) -> list[control_pb2.JobEvent]:
|
|
definition = command.job
|
|
self._validate_definition(definition)
|
|
replay = self._replay(
|
|
definition.job_id, command.expected_last_event_sequence
|
|
)
|
|
if replay:
|
|
return replay
|
|
event = self._event(
|
|
definition,
|
|
sequence=command.expected_last_event_sequence + 1,
|
|
revision=command.expected_job_revision,
|
|
event_type=control_pb2.JOB_EVENT_TYPE_ASSIGNED,
|
|
state=job_pb2.JOB_STATE_PREPARING,
|
|
committed=False,
|
|
)
|
|
self._record(definition, event)
|
|
return [event]
|
|
|
|
def execute(
|
|
self,
|
|
command: control_pb2.ExecuteStepCommand,
|
|
event_callback: Callable[[control_pb2.JobEvent], None] | None = None,
|
|
) -> list[control_pb2.JobEvent]:
|
|
definition = self._definition(command.job_id)
|
|
replay = self._replay(
|
|
command.job_id, command.expected_last_event_sequence
|
|
)
|
|
step_replay = [
|
|
event for event in replay
|
|
if event.progress.step == command.step
|
|
]
|
|
if step_replay and step_replay[-1].type in {
|
|
control_pb2.JOB_EVENT_TYPE_STEP_SUCCEEDED,
|
|
control_pb2.JOB_EVENT_TYPE_COMMITTED,
|
|
control_pb2.JOB_EVENT_TYPE_SUCCEEDED,
|
|
control_pb2.JOB_EVENT_TYPE_FAILED,
|
|
control_pb2.JOB_EVENT_TYPE_CLEANUP_REQUIRED,
|
|
control_pb2.JOB_EVENT_TYPE_CANCELLED,
|
|
}:
|
|
if event_callback is not None:
|
|
for event in replay:
|
|
event_callback(event)
|
|
return replay
|
|
if step_replay:
|
|
started = step_replay[0]
|
|
cursor = step_replay[-1]
|
|
emitted = list(step_replay)
|
|
else:
|
|
previous = replay[-1] if replay else None
|
|
started = self._event(
|
|
definition,
|
|
sequence=(
|
|
previous.sequence + 1
|
|
if previous is not None
|
|
else command.expected_last_event_sequence + 1
|
|
),
|
|
revision=(
|
|
previous.job_revision + 1
|
|
if previous is not None
|
|
else command.expected_job_revision + 1
|
|
),
|
|
event_type=control_pb2.JOB_EVENT_TYPE_STEP_STARTED,
|
|
state=job_pb2.JOB_STATE_RUNNING,
|
|
committed=(
|
|
self._committed(command.job_id)
|
|
or command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
|
|
or (
|
|
command.step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK
|
|
and self.store.get_job_artifact(
|
|
command.job_id, "qb-entry-removed"
|
|
) is not None
|
|
)
|
|
),
|
|
step=command.step,
|
|
step_state=job_pb2.STEP_STATE_RUNNING,
|
|
)
|
|
self._record(definition, started)
|
|
cursor = started
|
|
emitted = [started]
|
|
if event_callback is not None:
|
|
for event in emitted:
|
|
event_callback(event)
|
|
# A newly-started step must publish its first observation. In
|
|
# particular, a sparse Syncthing temporary file may retain the same
|
|
# allocated-block count for a long time while data is still needed;
|
|
# suppressing that first observation made a healthy transfer look
|
|
# permanently stalled to the control daemon.
|
|
speed_sample: list[float | int | None] = [None, 0]
|
|
|
|
def progress(
|
|
fraction: float,
|
|
bytes_complete: int = 0,
|
|
bytes_total: int = 0,
|
|
detail: str = "",
|
|
) -> None:
|
|
nonlocal cursor
|
|
now = time.monotonic()
|
|
previous_time = speed_sample[0]
|
|
elapsed = (
|
|
now - float(previous_time)
|
|
if previous_time is not None else 0.0
|
|
)
|
|
if previous_time is not None and fraction < 1 and elapsed < 1:
|
|
return
|
|
speed = (
|
|
max(0, bytes_complete - int(speed_sample[1])) / elapsed
|
|
if elapsed > 0 else 0
|
|
)
|
|
event = self._event(
|
|
definition,
|
|
sequence=cursor.sequence + 1,
|
|
revision=cursor.job_revision + 1,
|
|
event_type=control_pb2.JOB_EVENT_TYPE_PROGRESS,
|
|
state=job_pb2.JOB_STATE_RUNNING,
|
|
committed=cursor.committed,
|
|
step=command.step,
|
|
step_state=job_pb2.STEP_STATE_RUNNING,
|
|
)
|
|
event.progress.fraction_complete = max(
|
|
0.0, min(float(fraction), 1.0)
|
|
)
|
|
event.progress.bytes_complete = max(0, bytes_complete)
|
|
event.progress.bytes_total = max(0, bytes_total)
|
|
event.progress.approximate_bytes_per_second = speed
|
|
event.progress.detail = detail
|
|
completed_steps = max(0, _step_number(command.step) - 1)
|
|
event.progress.overall_fraction_complete = (
|
|
completed_steps + event.progress.fraction_complete
|
|
) / event.progress.display_step_total
|
|
self._record(definition, event)
|
|
emitted.append(event)
|
|
cursor = event
|
|
speed_sample[:] = [now, bytes_complete]
|
|
if event_callback is not None:
|
|
event_callback(event)
|
|
try:
|
|
result = self._execute_step(definition, command.step, progress)
|
|
except JobCancelled as error:
|
|
cancelling = self._event(
|
|
definition,
|
|
sequence=cursor.sequence + 1,
|
|
revision=cursor.job_revision + 1,
|
|
event_type=control_pb2.JOB_EVENT_TYPE_CANCELLING,
|
|
state=job_pb2.JOB_STATE_CANCELLING,
|
|
committed=started.committed,
|
|
step=command.step,
|
|
step_state=job_pb2.STEP_STATE_CANCELLED,
|
|
)
|
|
cancelling.error.code = common_pb2.ERROR_CODE_CANCELLED
|
|
cancelling.error.message = str(error)
|
|
self._record(definition, cancelling)
|
|
emitted.append(cancelling)
|
|
if event_callback is not None:
|
|
event_callback(cancelling)
|
|
return emitted
|
|
except Exception as error:
|
|
rollback_error: Exception | None = None
|
|
if (
|
|
definition.WhichOneof("spec") == "transfer"
|
|
and not started.committed
|
|
):
|
|
try:
|
|
if (
|
|
self.client_id == definition.transfer.target_client_id
|
|
and command.step in {
|
|
job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE,
|
|
job_pb2.JOB_STEP_KIND_QB_VERIFY,
|
|
}
|
|
):
|
|
self._rollback_target(definition)
|
|
elif (
|
|
self.client_id == definition.transfer.source_client_id
|
|
and command.step == job_pb2.JOB_STEP_KIND_SOURCE_STAGE
|
|
):
|
|
self._cleanup_staging(definition)
|
|
except Exception as compensation_error:
|
|
rollback_error = compensation_error
|
|
cleanup = (
|
|
(
|
|
command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
|
|
and started.committed
|
|
)
|
|
or (
|
|
command.step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK
|
|
and self.store.get_job_artifact(
|
|
definition.job_id, "qb-entry-removed"
|
|
) is not None
|
|
)
|
|
)
|
|
failed = self._event(
|
|
definition,
|
|
sequence=cursor.sequence + 1,
|
|
revision=cursor.job_revision + 1,
|
|
event_type=(
|
|
control_pb2.JOB_EVENT_TYPE_CLEANUP_REQUIRED
|
|
if cleanup else control_pb2.JOB_EVENT_TYPE_FAILED
|
|
),
|
|
state=(
|
|
job_pb2.JOB_STATE_CLEANUP_REQUIRED
|
|
if cleanup else job_pb2.JOB_STATE_FAILED
|
|
),
|
|
committed=started.committed,
|
|
step=command.step,
|
|
step_state=job_pb2.STEP_STATE_FAILED,
|
|
)
|
|
failed.error.code = _job_error_code(error)
|
|
failed.error.message = str(error) or type(error).__name__
|
|
failed.error.retryable = cleanup
|
|
if rollback_error is not None:
|
|
failed.error.code = (
|
|
common_pb2.ERROR_CODE_MANUAL_INTERVENTION_REQUIRED
|
|
)
|
|
failed.error.message = (
|
|
f"{failed.error.message}; compensation also failed: "
|
|
f"{rollback_error}"
|
|
)
|
|
failed.error.retryable = False
|
|
self._record(definition, failed)
|
|
emitted.append(failed)
|
|
if event_callback is not None:
|
|
event_callback(failed)
|
|
return emitted
|
|
sequence = cursor.sequence + 1
|
|
revision = cursor.job_revision + 1
|
|
committed = cursor.committed
|
|
event_type = control_pb2.JOB_EVENT_TYPE_STEP_SUCCEEDED
|
|
state = job_pb2.JOB_STATE_RUNNING
|
|
if command.step == job_pb2.JOB_STEP_KIND_QB_VERIFY:
|
|
committed = True
|
|
event_type = control_pb2.JOB_EVENT_TYPE_COMMITTED
|
|
elif command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP:
|
|
event_type = control_pb2.JOB_EVENT_TYPE_SUCCEEDED
|
|
state = job_pb2.JOB_STATE_SUCCEEDED
|
|
elif command.step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK:
|
|
committed = True
|
|
event_type = control_pb2.JOB_EVENT_TYPE_SUCCEEDED
|
|
state = job_pb2.JOB_STATE_SUCCEEDED
|
|
succeeded = self._event(
|
|
definition,
|
|
sequence=sequence,
|
|
revision=revision,
|
|
event_type=event_type,
|
|
state=state,
|
|
committed=committed,
|
|
step=command.step,
|
|
step_state=job_pb2.STEP_STATE_SUCCEEDED,
|
|
)
|
|
if result is not None:
|
|
succeeded.observed_placement.CopyFrom(result)
|
|
self._record(definition, succeeded)
|
|
emitted.append(succeeded)
|
|
if event_callback is not None:
|
|
event_callback(succeeded)
|
|
return emitted
|
|
|
|
def cancel(
|
|
self, command: control_pb2.CancelJobCommand
|
|
) -> list[control_pb2.JobEvent]:
|
|
definition = self._definition(command.job_id)
|
|
self.request_cancel(command.job_id)
|
|
replay = self._replay(
|
|
command.job_id, command.expected_last_event_sequence
|
|
)
|
|
if replay:
|
|
return replay
|
|
committed = self._committed(command.job_id)
|
|
if definition.WhichOneof("spec") == "eviction":
|
|
removed = self.store.get_job_artifact(
|
|
definition.job_id, "qb-entry-removed"
|
|
)
|
|
placement = None
|
|
if removed is not None:
|
|
placement = self._execute_eviction_step(
|
|
definition, job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK
|
|
)
|
|
committed = True
|
|
event_type = control_pb2.JOB_EVENT_TYPE_CANCELLED
|
|
state = job_pb2.JOB_STATE_CANCELLED
|
|
elif committed:
|
|
if definition.transfer.source_client_id == self.client_id:
|
|
self._cleanup_staging(definition)
|
|
event_type = control_pb2.JOB_EVENT_TYPE_CANCELLED
|
|
state = job_pb2.JOB_STATE_CANCELLED
|
|
placement = None
|
|
elif definition.transfer.source_client_id == self.client_id:
|
|
self._cleanup_staging(definition)
|
|
temporary = self._metainfo_path(definition.job_id)
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
event_type = control_pb2.JOB_EVENT_TYPE_ROLLBACK_SUCCEEDED
|
|
state = job_pb2.JOB_STATE_ROLLING_BACK
|
|
placement = None
|
|
else:
|
|
self._rollback_target(definition)
|
|
self._cleanup_staging(definition)
|
|
event_type = control_pb2.JOB_EVENT_TYPE_CANCELLED
|
|
state = job_pb2.JOB_STATE_CANCELLED
|
|
placement = None
|
|
event = self._event(
|
|
definition,
|
|
sequence=command.expected_last_event_sequence + 1,
|
|
revision=command.expected_job_revision + 1,
|
|
event_type=event_type,
|
|
state=state,
|
|
committed=committed,
|
|
step=job_pb2.JOB_STEP_KIND_ROLLBACK,
|
|
step_state=job_pb2.STEP_STATE_SUCCEEDED,
|
|
)
|
|
if placement is not None:
|
|
event.observed_placement.CopyFrom(placement)
|
|
self._record(definition, event)
|
|
return [event]
|
|
|
|
def _execute_step(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
step: int,
|
|
progress: Callable[[float, int, int, str], None],
|
|
):
|
|
self._raise_if_cancelled(definition.job_id)
|
|
if definition.WhichOneof("spec") == "eviction":
|
|
return self._execute_eviction_step(definition, step)
|
|
if step == job_pb2.JOB_STEP_KIND_SOURCE_STAGE:
|
|
self._source_stage(definition, progress)
|
|
return None
|
|
if step == job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER:
|
|
self._wait_for_syncthing(definition, progress)
|
|
return None
|
|
if step == job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE:
|
|
self._target_materialize(definition, progress)
|
|
return None
|
|
if step == job_pb2.JOB_STEP_KIND_QB_VERIFY:
|
|
return self._qb_verify(definition, progress)
|
|
if step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP:
|
|
self._cleanup_staging(definition)
|
|
temporary_metainfo = self._metainfo_path(definition.job_id)
|
|
if temporary_metainfo.exists():
|
|
temporary_metainfo.unlink()
|
|
for directory in (
|
|
temporary_metainfo.parent,
|
|
temporary_metainfo.parent.parent,
|
|
):
|
|
try:
|
|
directory.rmdir()
|
|
except OSError:
|
|
break
|
|
return None
|
|
raise JobExecutionError("job step is unsupported")
|
|
|
|
def _execute_eviction_step(
|
|
self, definition: job_pb2.JobDefinition, step: int
|
|
):
|
|
if definition.eviction.cache_client_id != self.client_id:
|
|
raise JobExecutionError("eviction step was sent to the wrong client")
|
|
info_hash = _info_hash(definition)
|
|
if step == job_pb2.JOB_STEP_KIND_VERIFY_ARCHIVE_COVERAGE:
|
|
covered = {
|
|
index
|
|
for proof in definition.eviction.archive_coverage
|
|
for index in _selection_indices(proof.covered_files)
|
|
}
|
|
requested = set(
|
|
_selection_indices(definition.eviction.files_to_evict)
|
|
)
|
|
if not requested or requested - covered:
|
|
raise JobExecutionError(
|
|
"archive coverage no longer covers the cache selection"
|
|
)
|
|
resource = self._resource(definition)
|
|
current = _resource_fingerprint(resource, self.client_id)
|
|
if not _fingerprint_matches(
|
|
current, definition.eviction.cache_fingerprint
|
|
):
|
|
raise JobExecutionError(
|
|
"cache resource changed after eviction confirmation"
|
|
)
|
|
verify_and_snapshot(
|
|
job_id=definition.job_id,
|
|
resource=resource,
|
|
selected_indices=requested,
|
|
qb_root=self.qb_root,
|
|
store=self.store,
|
|
)
|
|
return None
|
|
if step == job_pb2.JOB_STEP_KIND_QB_REMOVE_ENTRY:
|
|
remove_qb_entry(
|
|
job_id=definition.job_id,
|
|
torrent_hash=info_hash,
|
|
qbittorrent=self.qbittorrent,
|
|
store=self.store,
|
|
)
|
|
return None
|
|
if step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK:
|
|
safe_unlink(
|
|
job_id=definition.job_id,
|
|
qb_root=self.qb_root,
|
|
qbittorrent=self.qbittorrent,
|
|
store=self.store,
|
|
)
|
|
placement = resource_pb2.Placement(
|
|
client_id=self.client_id,
|
|
state=resource_pb2.PLACEMENT_STATE_ABSENT,
|
|
generation=definition.eviction.cache_placement_generation + 1,
|
|
created_by_job_id=definition.job_id,
|
|
)
|
|
placement.resource_id.CopyFrom(definition.resource_id)
|
|
placement.fingerprint.CopyFrom(
|
|
definition.eviction.cache_fingerprint
|
|
)
|
|
placement.verified_at.GetCurrentTime()
|
|
return placement
|
|
raise JobExecutionError("eviction job step is unsupported")
|
|
|
|
def _source_stage(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
progress: Callable[[float, int, int, str], None],
|
|
) -> None:
|
|
if definition.transfer.source_client_id != self.client_id:
|
|
raise JobExecutionError("source stage was sent to the wrong client")
|
|
resource = self._resource(definition)
|
|
if not _fingerprint_matches(
|
|
_resource_fingerprint(resource, self.client_id),
|
|
definition.transfer.source_fingerprint,
|
|
):
|
|
raise JobExecutionError(
|
|
"source resource changed after job confirmation"
|
|
)
|
|
self._reject_unsafe_partfile(definition, resource)
|
|
indices = _selection_indices(definition.transfer.transfer_delta_files)
|
|
by_index = {item.file_index: item for item in resource.files}
|
|
if not indices or any(index not in by_index for index in indices):
|
|
raise JobExecutionError("transfer delta file selection is invalid")
|
|
if any(
|
|
not by_index[index].selected
|
|
or by_index[index].completed_bytes != by_index[index].logical_bytes
|
|
for index in indices
|
|
):
|
|
raise JobExecutionError("transfer delta contains unavailable files")
|
|
manifest = transfer_pb2.TransferManifest(
|
|
manifest_version=1,
|
|
job_id=definition.job_id,
|
|
source_client_id=definition.transfer.source_client_id,
|
|
target_client_id=definition.transfer.target_client_id,
|
|
route_id=definition.transfer.route_id,
|
|
)
|
|
manifest.resource_id.CopyFrom(definition.resource_id)
|
|
manifest.requested_files.CopyFrom(definition.transfer.requested_files)
|
|
manifest.target_baseline_files.CopyFrom(
|
|
definition.transfer.target_baseline_files
|
|
)
|
|
manifest.transfer_delta_files.CopyFrom(
|
|
definition.transfer.transfer_delta_files
|
|
)
|
|
manifest.source_fingerprint.CopyFrom(
|
|
definition.transfer.source_fingerprint
|
|
)
|
|
if definition.transfer.HasField("target_baseline_fingerprint"):
|
|
manifest.target_baseline_fingerprint.CopyFrom(
|
|
definition.transfer.target_baseline_fingerprint
|
|
)
|
|
manifest.created_at.GetCurrentTime()
|
|
for index in indices:
|
|
item = by_index[index]
|
|
manifest.files.add(
|
|
file_index=index,
|
|
payload_relative_path=f"payload/{item.canonical_path}",
|
|
target_canonical_path=item.canonical_path,
|
|
logical_bytes=item.logical_bytes,
|
|
)
|
|
metainfo_path = self._metainfo_path(definition.job_id)
|
|
metainfo_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if not metainfo_path.exists():
|
|
metainfo_path.write_bytes(resource.metainfo_bytes)
|
|
artifact = manifest.artifacts.add(
|
|
kind=transfer_pb2.ARTIFACT_KIND_TORRENT_FILE,
|
|
payload_relative_path="metainfo/source.torrent",
|
|
logical_bytes=len(resource.metainfo_bytes),
|
|
format="application/x-bittorrent",
|
|
sha256_hex=hashlib.sha256(resource.metainfo_bytes).hexdigest(),
|
|
)
|
|
del artifact
|
|
route_root = self.route_path(definition.transfer.route_id)
|
|
# Staging is normally zero-copy when qB's data root and the paired
|
|
# Syncthing route share a filesystem. Do not reserve the complete
|
|
# logical payload in that case: FileMaterializer will use link(2),
|
|
# which consumes only directory/inode metadata. Retain the metainfo
|
|
# allowance and reserve, and account for any source files that really
|
|
# must fall back to a data-copy path.
|
|
self._require_space(
|
|
route_root,
|
|
self._copy_required_bytes(
|
|
self.qb_root,
|
|
route_root,
|
|
(
|
|
(entry.target_canonical_path, entry.logical_bytes)
|
|
for entry in manifest.files
|
|
),
|
|
)
|
|
+ len(resource.metainfo_bytes),
|
|
)
|
|
stage_transfer(
|
|
manifest,
|
|
source_root=self.qb_root,
|
|
sync_root=route_root,
|
|
store=self.store,
|
|
artifact_sources={"metainfo/source.torrent": metainfo_path},
|
|
sparse_supported=self.sparse_supported,
|
|
cancel_check=lambda: self._raise_if_cancelled(
|
|
definition.job_id
|
|
),
|
|
progress=lambda completed, total: progress(
|
|
completed / total if total else 1,
|
|
completed,
|
|
total,
|
|
f"staged {completed} of {total} bytes",
|
|
),
|
|
)
|
|
try:
|
|
self._observer(definition).rescan()
|
|
except RouteSetupError as exc:
|
|
# A manual Syncthing scan may take longer than the bounded REST
|
|
# request timeout for a large newly linked file. The folder watch
|
|
# and the following transfer observation still converge, so do
|
|
# not roll back an otherwise durable staged transfer.
|
|
logger.warning("syncthing_rescan_deferred", extra={
|
|
"job_id": definition.job_id,
|
|
"error_type": type(exc).__name__,
|
|
})
|
|
|
|
def _wait_for_syncthing(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
progress: Callable[[float, int, int, str], None],
|
|
) -> None:
|
|
if definition.transfer.target_client_id != self.client_id:
|
|
raise JobExecutionError(
|
|
"Syncthing completion was sent to the wrong client"
|
|
)
|
|
observer = self._observer(definition)
|
|
last_reported: tuple[int, int] | None = None
|
|
while True:
|
|
self._raise_if_cancelled(definition.job_id)
|
|
try:
|
|
status = observer.status()
|
|
sample = (status.bytes_complete, status.bytes_total)
|
|
if sample != last_reported:
|
|
progress(
|
|
status.fraction_complete,
|
|
status.bytes_complete,
|
|
status.bytes_total,
|
|
f"{status.needed_items} Syncthing items still needed",
|
|
)
|
|
last_reported = sample
|
|
if status.complete:
|
|
return
|
|
except (FileNotFoundError, TransferIntegrityError):
|
|
# Syncthing can expose the per-job directory before the
|
|
# ready marker and manifest have arrived atomically as a set.
|
|
pass
|
|
time.sleep(self.poll_interval)
|
|
|
|
def _target_materialize(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
progress: Callable[[float, int, int, str], None],
|
|
) -> None:
|
|
if definition.transfer.target_client_id != self.client_id:
|
|
raise JobExecutionError(
|
|
"target materialization was sent to the wrong client"
|
|
)
|
|
published = load_published_transfer(self._job_directory(definition))
|
|
# The target can likewise hardlink an arrived Syncthing payload into
|
|
# qB's content root when those directories share a filesystem.
|
|
self._require_space(
|
|
self.qb_root,
|
|
self._copy_required_bytes(
|
|
published.job_directory,
|
|
self.qb_root,
|
|
(
|
|
(entry.payload_relative_path, entry.logical_bytes)
|
|
for entry in published.manifest.files
|
|
),
|
|
),
|
|
)
|
|
info_hash = _info_hash(definition)
|
|
resource = self.qbittorrent.get_resource(info_hash)
|
|
if resource is not None:
|
|
self._reject_unsafe_partfile(definition, resource)
|
|
if definition.transfer.HasField("target_baseline_fingerprint"):
|
|
if resource is None or not _fingerprint_matches(
|
|
_resource_fingerprint(resource, self.client_id),
|
|
definition.transfer.target_baseline_fingerprint,
|
|
):
|
|
raise JobExecutionError(
|
|
"target resource changed after job confirmation"
|
|
)
|
|
elif resource is not None:
|
|
raise JobExecutionError(
|
|
"target resource appeared after job confirmation"
|
|
)
|
|
baseline = {
|
|
"present": resource is not None,
|
|
"selected_file_indices": (
|
|
_selection_indices(resource.summary.selected_files)
|
|
if resource is not None else []
|
|
),
|
|
"stopped": (
|
|
resource is not None
|
|
and resource.summary.runtime_state
|
|
== resource_pb2.TORRENT_RUNTIME_STATE_STOPPED
|
|
),
|
|
"total_file_count": len(published.manifest.files),
|
|
}
|
|
self.store.put_job_artifact(
|
|
definition.job_id, "target-baseline", baseline
|
|
)
|
|
materialize_transfer(
|
|
published,
|
|
target_root=self.qb_root,
|
|
store=self.store,
|
|
sparse_supported=self.sparse_supported,
|
|
cancel_check=lambda: self._raise_if_cancelled(
|
|
definition.job_id
|
|
),
|
|
progress=lambda completed, total: progress(
|
|
completed / total if total else 1,
|
|
completed,
|
|
total,
|
|
f"materialized {completed} of {total} bytes",
|
|
),
|
|
)
|
|
|
|
def _qb_verify(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
progress: Callable[[float, int, int, str], None],
|
|
):
|
|
if definition.transfer.target_client_id != self.client_id:
|
|
raise JobExecutionError("qB verification was sent to the wrong client")
|
|
published = load_published_transfer(self._job_directory(definition))
|
|
metainfo_path = (
|
|
published.job_directory / "metainfo" / "source.torrent"
|
|
)
|
|
info_hash = (
|
|
definition.resource_id.info_hash_v1_hex
|
|
or definition.resource_id.info_hash_v2_hex
|
|
)
|
|
resource = self.qbittorrent.get_resource(info_hash)
|
|
should_start = (
|
|
resource is None
|
|
or resource.summary.runtime_state
|
|
!= resource_pb2.TORRENT_RUNTIME_STATE_STOPPED
|
|
)
|
|
if resource is None:
|
|
self.qbittorrent.add_stopped_with_retry(
|
|
metainfo_path.read_bytes(),
|
|
self.qb_api_root.as_posix(),
|
|
info_hash,
|
|
)
|
|
resource = self.qbittorrent.get_resource(info_hash)
|
|
if resource is None:
|
|
raise JobExecutionError(
|
|
"added qBittorrent resource did not become visible"
|
|
)
|
|
qb_torrent_id = resource.summary.qb_torrent_id
|
|
target_union = _union_selection(
|
|
definition.transfer.target_baseline_files,
|
|
definition.transfer.transfer_delta_files,
|
|
)
|
|
selected = _selection_indices(target_union)
|
|
total_files = len(
|
|
decode_message_metainfo(metainfo_path).files
|
|
)
|
|
self.qbittorrent.set_selection(
|
|
qb_torrent_id, selected, total_files
|
|
)
|
|
self.qbittorrent.recheck_and_wait(
|
|
qb_torrent_id,
|
|
selected,
|
|
timeout=self.verification_timeout,
|
|
poll_interval=self.poll_interval,
|
|
cancel_check=lambda: self._raise_if_cancelled(
|
|
definition.job_id
|
|
),
|
|
progress_callback=lambda fraction: progress(
|
|
fraction, 0, 0, "qBittorrent stopped recheck"
|
|
),
|
|
)
|
|
if should_start:
|
|
self.qbittorrent.start(qb_torrent_id)
|
|
verified = self.qbittorrent.get_resource(info_hash)
|
|
if verified is None:
|
|
raise JobExecutionError(
|
|
"verified qBittorrent resource disappeared"
|
|
)
|
|
placement = resource_pb2.Placement(
|
|
client_id=self.client_id,
|
|
state=resource_pb2.PLACEMENT_STATE_PRESENT,
|
|
generation=(
|
|
definition.transfer.expected_target_placement_generation + 1
|
|
if definition.transfer.HasField(
|
|
"expected_target_placement_generation"
|
|
)
|
|
else 1
|
|
),
|
|
created_by_job_id=definition.job_id,
|
|
)
|
|
placement.resource_id.CopyFrom(definition.resource_id)
|
|
placement.verified_files.CopyFrom(target_union)
|
|
metainfo = decode_message_metainfo(metainfo_path)
|
|
placement.verified_logical_bytes = sum(
|
|
metainfo.files[index].logical_bytes for index in selected
|
|
)
|
|
placement.fingerprint.CopyFrom(
|
|
_resource_fingerprint(verified, self.client_id)
|
|
)
|
|
placement.verified_at.GetCurrentTime()
|
|
return placement
|
|
|
|
def _rollback_target(self, definition: job_pb2.JobDefinition) -> None:
|
|
baseline_row = self.store.get_job_artifact(
|
|
definition.job_id, "target-baseline"
|
|
)
|
|
if baseline_row is None:
|
|
compensate_materialized_files(
|
|
job_id=definition.job_id,
|
|
qb_root=self.qb_root,
|
|
store=self.store,
|
|
)
|
|
return
|
|
baseline = baseline_row["value"]
|
|
info_hash = _info_hash(definition)
|
|
present = self.qbittorrent.get_resource(info_hash)
|
|
if present is not None:
|
|
qb_torrent_id = present.summary.qb_torrent_id
|
|
if baseline.get("present"):
|
|
selected = baseline.get("selected_file_indices")
|
|
if isinstance(selected, list) and selected:
|
|
self.qbittorrent.set_selection(
|
|
qb_torrent_id, selected, len(present.files)
|
|
)
|
|
if baseline.get("stopped"):
|
|
self.qbittorrent.stop(qb_torrent_id)
|
|
else:
|
|
self.qbittorrent.start(qb_torrent_id)
|
|
else:
|
|
self.qbittorrent.delete_entry(qb_torrent_id)
|
|
compensate_materialized_files(
|
|
job_id=definition.job_id,
|
|
qb_root=self.qb_root,
|
|
store=self.store,
|
|
)
|
|
|
|
def _cleanup_staging(self, definition: job_pb2.JobDefinition) -> None:
|
|
job_directory = self._job_directory(definition)
|
|
try:
|
|
if cleanup_transfer(job_directory):
|
|
return
|
|
except FileNotFoundError:
|
|
pass
|
|
if not job_directory.is_dir():
|
|
return
|
|
compensate_materialized_files(
|
|
job_id=definition.job_id,
|
|
qb_root=job_directory,
|
|
store=self.store,
|
|
)
|
|
cleanup_partial_transfer(
|
|
job_directory,
|
|
job_id=definition.job_id,
|
|
store=self.store,
|
|
)
|
|
for name in ("ready.json", "manifest.json"):
|
|
candidate = job_directory / name
|
|
if candidate.is_file() and not candidate.is_symlink():
|
|
candidate.unlink()
|
|
try:
|
|
job_directory.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
def _reject_unsafe_partfile(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
resource: NormalizedResource,
|
|
) -> None:
|
|
roots = {self.qb_root}
|
|
for item in resource.files:
|
|
candidate = self.qb_root / PurePosixPath(item.canonical_path).parts[0]
|
|
roots.add(candidate if candidate.is_dir() else candidate.parent)
|
|
hashes = {
|
|
value.lower() for value in (
|
|
definition.resource_id.info_hash_v1_hex,
|
|
definition.resource_id.info_hash_v2_hex,
|
|
) if value
|
|
}
|
|
for root in roots:
|
|
if not root.is_dir():
|
|
continue
|
|
for candidate in root.glob("*.parts"):
|
|
lowered = candidate.name.lower()
|
|
if lowered == ".parts" or any(value in lowered for value in hashes):
|
|
raise JobExecutionError(
|
|
"resource uses a qBittorrent partfile that cannot be "
|
|
"safely transferred by this client"
|
|
)
|
|
|
|
def _raise_if_cancelled(self, job_id: str) -> None:
|
|
event = self._cancel_events.get(job_id)
|
|
if event is not None and event.is_set():
|
|
raise JobCancelled("job cancellation requested")
|
|
|
|
def _require_space(self, root: Path, required_bytes: int) -> None:
|
|
available = shutil.disk_usage(root).free
|
|
required = required_bytes + self.free_space_reserve_bytes
|
|
if available < required:
|
|
raise JobExecutionError(
|
|
f"insufficient free space: {available} bytes available, "
|
|
f"{required} bytes required including reserve"
|
|
)
|
|
|
|
@staticmethod
|
|
def _copy_required_bytes(
|
|
source_root: Path,
|
|
destination_root: Path,
|
|
files: Iterable[tuple[str, int]],
|
|
) -> int:
|
|
"""Return logical bytes that cannot be materialized by hardlink.
|
|
|
|
A hardlink is possible only for regular files on the destination
|
|
filesystem. Conservatively charge a file when it cannot be inspected;
|
|
the normal materializer will then provide the precise integrity error.
|
|
"""
|
|
|
|
destination_device = destination_root.stat().st_dev
|
|
required = 0
|
|
for relative_path, logical_bytes in files:
|
|
relative = PurePosixPath(relative_path)
|
|
source = source_root.joinpath(*relative.parts)
|
|
try:
|
|
metadata = source.stat(follow_symlinks=False)
|
|
except OSError:
|
|
required += logical_bytes
|
|
continue
|
|
if (
|
|
not stat.S_ISREG(metadata.st_mode)
|
|
or metadata.st_dev != destination_device
|
|
or _mount_id(source) != _mount_id(destination_root)
|
|
):
|
|
required += logical_bytes
|
|
return required
|
|
|
|
def _observer(
|
|
self, definition: job_pb2.JobDefinition
|
|
) -> SyncthingTransferObserver:
|
|
return SyncthingTransferObserver(
|
|
self.syncthing_transport,
|
|
definition.transfer.route_id,
|
|
f".archive-control/jobs/{definition.job_id}",
|
|
self._job_directory(definition),
|
|
)
|
|
|
|
def _job_directory(self, definition: job_pb2.JobDefinition) -> Path:
|
|
return (
|
|
self.route_path(definition.transfer.route_id)
|
|
/ ".archive-control" / "jobs" / definition.job_id
|
|
)
|
|
|
|
def _metainfo_path(self, job_id: str) -> Path:
|
|
return self.store.database.parent / "artifacts" / job_id / "source.torrent"
|
|
|
|
def _resource(self, definition: job_pb2.JobDefinition) -> NormalizedResource:
|
|
for info_hash in (
|
|
definition.resource_id.info_hash_v1_hex,
|
|
definition.resource_id.info_hash_v2_hex,
|
|
):
|
|
if info_hash:
|
|
resource = self.qbittorrent.get_resource(info_hash)
|
|
if resource is not None:
|
|
return resource
|
|
raise JobExecutionError("source resource is not present")
|
|
|
|
def _definition(self, job_id: str) -> job_pb2.JobDefinition:
|
|
rows = self.store.job_snapshot_rows([job_id])
|
|
if not rows:
|
|
raise JobExecutionError("job is not assigned")
|
|
return decode_message(
|
|
str(rows[0]["definition_json"]), job_pb2.JobDefinition()
|
|
)
|
|
|
|
def _committed(self, job_id: str) -> bool:
|
|
rows = self.store.job_snapshot_rows([job_id])
|
|
return bool(rows and rows[0]["committed"])
|
|
|
|
def _replay(
|
|
self, job_id: str, after_sequence: int
|
|
) -> list[control_pb2.JobEvent]:
|
|
return [
|
|
decode_message(str(row["event_json"]), control_pb2.JobEvent())
|
|
for row in self.store.job_event_rows(job_id, after_sequence)
|
|
]
|
|
|
|
def _record(
|
|
self,
|
|
definition: job_pb2.JobDefinition,
|
|
event: control_pb2.JobEvent,
|
|
) -> None:
|
|
self.store.record_job_event(
|
|
job_id=definition.job_id,
|
|
definition_json=encode_message(definition),
|
|
event_id=event.event_id,
|
|
event_json=encode_message(event),
|
|
state=job_pb2.JobState.Name(event.state),
|
|
revision=event.job_revision,
|
|
sequence=event.sequence,
|
|
committed=event.committed,
|
|
)
|
|
|
|
@staticmethod
|
|
def _event(
|
|
definition: job_pb2.JobDefinition,
|
|
*,
|
|
sequence: int,
|
|
revision: int,
|
|
event_type: int,
|
|
state: int,
|
|
committed: bool,
|
|
step: int = job_pb2.JOB_STEP_KIND_UNSPECIFIED,
|
|
step_state: int = job_pb2.STEP_STATE_UNSPECIFIED,
|
|
) -> control_pb2.JobEvent:
|
|
event = control_pb2.JobEvent(
|
|
event_id=str(uuid.uuid4()),
|
|
job_id=definition.job_id,
|
|
sequence=sequence,
|
|
job_revision=revision,
|
|
type=event_type,
|
|
state=state,
|
|
committed=committed,
|
|
)
|
|
event.occurred_at.GetCurrentTime()
|
|
if step != job_pb2.JOB_STEP_KIND_UNSPECIFIED:
|
|
event.progress.step = step
|
|
event.progress.state = step_state
|
|
event.progress.fraction_complete = (
|
|
1 if step_state == job_pb2.STEP_STATE_SUCCEEDED else 0
|
|
)
|
|
event.progress.overall_fraction_complete = _overall(step, step_state)
|
|
event.progress.display_step_number = _step_number(step)
|
|
event.progress.display_step_total = (
|
|
3
|
|
if definition.operation == job_pb2.JOB_OPERATION_EVICT_CACHE
|
|
else 5
|
|
)
|
|
event.progress.last_progress_at.CopyFrom(event.occurred_at)
|
|
return event
|
|
|
|
@staticmethod
|
|
def _validate_definition(definition: job_pb2.JobDefinition) -> None:
|
|
if not definition.job_id:
|
|
raise JobExecutionError("job definition has no ID")
|
|
if definition.operation in {
|
|
job_pb2.JOB_OPERATION_ARCHIVE,
|
|
job_pb2.JOB_OPERATION_UNARCHIVE,
|
|
} and definition.WhichOneof("spec") == "transfer":
|
|
return
|
|
if (
|
|
definition.operation == job_pb2.JOB_OPERATION_EVICT_CACHE
|
|
and definition.WhichOneof("spec") == "eviction"
|
|
):
|
|
return
|
|
raise JobExecutionError("job definition operation/spec is invalid")
|
|
|
|
|
|
def _selection_indices(selection) -> list[int]:
|
|
result: list[int] = []
|
|
previous = -1
|
|
for item in selection.ranges:
|
|
if item.first > item.last or item.first <= previous:
|
|
raise JobExecutionError("selection ranges are invalid")
|
|
result.extend(range(item.first, item.last + 1))
|
|
previous = item.last
|
|
return result
|
|
|
|
|
|
def _union_selection(*selections) -> resource_pb2.SelectionSet:
|
|
indices = sorted({
|
|
index
|
|
for selection in selections
|
|
for index in _selection_indices(selection)
|
|
})
|
|
result = resource_pb2.SelectionSet()
|
|
if not indices:
|
|
return result
|
|
first = previous = indices[0]
|
|
for index in indices[1:]:
|
|
if index == previous + 1:
|
|
previous = index
|
|
continue
|
|
result.ranges.add(first=first, last=previous)
|
|
first = previous = index
|
|
result.ranges.add(first=first, last=previous)
|
|
return result
|
|
|
|
|
|
def _step_number(step: int) -> int:
|
|
return {
|
|
job_pb2.JOB_STEP_KIND_SOURCE_STAGE: 1,
|
|
job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER: 2,
|
|
job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE: 3,
|
|
job_pb2.JOB_STEP_KIND_QB_VERIFY: 4,
|
|
job_pb2.JOB_STEP_KIND_STAGING_CLEANUP: 5,
|
|
job_pb2.JOB_STEP_KIND_VERIFY_ARCHIVE_COVERAGE: 1,
|
|
job_pb2.JOB_STEP_KIND_QB_REMOVE_ENTRY: 2,
|
|
job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK: 3,
|
|
job_pb2.JOB_STEP_KIND_ROLLBACK: 1,
|
|
}.get(step, 0)
|
|
|
|
|
|
def _overall(step: int, state: int) -> float:
|
|
number = _step_number(step)
|
|
total = (
|
|
3
|
|
if step in {
|
|
job_pb2.JOB_STEP_KIND_VERIFY_ARCHIVE_COVERAGE,
|
|
job_pb2.JOB_STEP_KIND_QB_REMOVE_ENTRY,
|
|
job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK,
|
|
}
|
|
else 5
|
|
)
|
|
return max(
|
|
0, number - (0 if state == job_pb2.STEP_STATE_SUCCEEDED else 1)
|
|
) / total
|
|
|
|
|
|
def decode_message_metainfo(path: Path):
|
|
from archive_clients.bencode import decode_metainfo
|
|
|
|
return decode_metainfo(path.read_bytes())
|
|
|
|
|
|
def _resource_fingerprint(
|
|
resource: NormalizedResource, client_id: str
|
|
) -> resource_pb2.ResourceStateFingerprint:
|
|
summary = resource.summary
|
|
fingerprint = resource_pb2.ResourceStateFingerprint(
|
|
client_id=client_id,
|
|
qb_torrent_id=summary.qb_torrent_id,
|
|
content_revision=summary.content_revision,
|
|
runtime_state=summary.runtime_state,
|
|
)
|
|
fingerprint.resource_id.CopyFrom(summary.resource_id)
|
|
fingerprint.selected_files.CopyFrom(summary.selected_files)
|
|
fingerprint.selected_complete_files.CopyFrom(
|
|
summary.selected_complete_files
|
|
)
|
|
fingerprint.observed_at.CopyFrom(summary.observed_at)
|
|
return fingerprint
|
|
|
|
|
|
def _info_hash(definition: job_pb2.JobDefinition) -> str:
|
|
value = (
|
|
definition.resource_id.info_hash_v1_hex
|
|
or definition.resource_id.info_hash_v2_hex
|
|
)
|
|
if not value:
|
|
raise JobExecutionError("job resource identity has no info hash")
|
|
return value
|
|
|
|
|
|
def _fingerprint_matches(
|
|
current: resource_pb2.ResourceStateFingerprint,
|
|
expected: resource_pb2.ResourceStateFingerprint,
|
|
) -> bool:
|
|
left = resource_pb2.ResourceStateFingerprint()
|
|
right = resource_pb2.ResourceStateFingerprint()
|
|
left.CopyFrom(current)
|
|
right.CopyFrom(expected)
|
|
left.ClearField("observed_at")
|
|
right.ClearField("observed_at")
|
|
for value in (left, right):
|
|
if value.runtime_state in {
|
|
resource_pb2.TORRENT_RUNTIME_STATE_QUEUED,
|
|
resource_pb2.TORRENT_RUNTIME_STATE_SEEDING,
|
|
resource_pb2.TORRENT_RUNTIME_STATE_STALLED,
|
|
}:
|
|
value.runtime_state = (
|
|
resource_pb2.TORRENT_RUNTIME_STATE_SEEDING
|
|
)
|
|
return left == right
|
|
|
|
|
|
def _job_error_code(error: Exception) -> int:
|
|
if isinstance(error, QBittorrentDownloadAttempt):
|
|
return common_pb2.ERROR_CODE_MANUAL_INTERVENTION_REQUIRED
|
|
if isinstance(error, TransferIntegrityError):
|
|
return common_pb2.ERROR_CODE_INTEGRITY_CHECK_FAILED
|
|
if isinstance(error, PermissionError):
|
|
return common_pb2.ERROR_CODE_PERMISSION_DENIED
|
|
if isinstance(error, RouteSetupError):
|
|
return common_pb2.ERROR_CODE_UNAVAILABLE
|
|
if isinstance(error, JobExecutionError):
|
|
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
|
|
if isinstance(error, TransferError):
|
|
return common_pb2.ERROR_CODE_PATH_CONFLICT
|
|
if isinstance(error, EvictionError):
|
|
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
|
|
return common_pb2.ERROR_CODE_INTERNAL
|
|
|
|
|
|
def _mount_id(path: Path) -> str | None:
|
|
"""Return Linux's effective mount ID for a path when procfs is available.
|
|
|
|
Bind mounts can share ``st_dev`` while still rejecting ``link(2)`` with
|
|
``EXDEV``. Mount IDs distinguish that case without creating probe files
|
|
inside a Syncthing folder.
|
|
"""
|
|
|
|
try:
|
|
target = os.path.realpath(path)
|
|
best: tuple[int, str] | None = None
|
|
with open("/proc/self/mountinfo", encoding="utf-8") as source:
|
|
for line in source:
|
|
fields = line.rstrip("\n").split(" ")
|
|
if len(fields) < 5:
|
|
continue
|
|
mountpoint = _unescape_mount_path(fields[4])
|
|
if target != mountpoint and not target.startswith(
|
|
mountpoint.rstrip("/") + "/"
|
|
):
|
|
continue
|
|
candidate = (len(mountpoint), fields[0])
|
|
if best is None or candidate[0] > best[0]:
|
|
best = candidate
|
|
return None if best is None else best[1]
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _unescape_mount_path(value: str) -> str:
|
|
return (
|
|
value.replace("\\040", " ")
|
|
.replace("\\011", "\t")
|
|
.replace("\\012", "\n")
|
|
.replace("\\134", "\\")
|
|
)
|