How Motrix's TaskRecoveryService Recovers Tasks After a Crash: 8-Step Source Code Deep Dive

Motrix's TaskRecoveryService recovers crashed downloads by scanning in-flight tasks, mapping live engine state, inspecting the filesystem, and running a pure decision function that issues one of six RecoveryActions such as ResumeFromRename, AdoptExistingGid, or MarkError.

When Motrix restarts after an unexpected termination, the TaskRecoveryServiceImpl in the agalwood/Motrix repository orchestrates an 8-step pipeline to restore downloads without data loss. This service combines persisted task metadata from the TaskManager, real-time engine state from the active adapter, and filesystem presence checks to decide exactly how each interrupted task should resume. Understanding how Motrix's TaskRecoveryService recovers tasks after a crash reveals a defensive design that prioritizes atomic moves, live GID adoption, and detailed diagnostic reporting.

Step 1: Collect In-Flight Tasks from the TaskManager

The entry point is recoverOnStartup inside src/core/task/task-recovery-service.ts. It fetches every persisted task from the TaskManager and filters for any item whose transitionPhase is not Idle or whose status is Finalizing【/src/core/task/task-recovery-service.ts#L174-L190】. Each qualifying task is cloned if a mutable set method exists, ensuring the recovery logic works on copies rather than live references. The total count is stored in report.totalScanned.

Step 2: Gather Live Engine Information by Info-Hash

Next, the service queries the active download engine via adapter.listActiveAndWaiting() to retrieve the current set of active and waiting rows【/src/core/task/task-recovery-service.ts#L202-L218】. It builds a map called byInfoHash that associates each info-hash to the set of engine-provided GIDs currently in flight. This map is later used to adopt a live seeding identity when a task's content is still active in the engine but the local GID was lost during the crash.

Step 3: Index Persisted Task Metadata

Two auxiliary indexes are derived from the complete published task list to accelerate lookups during decision-making【/src/core/task/task-recovery-service.ts#L220-L232】:

  • ownerByGid – Maps each persisted GID to the task ID that currently owns it.
  • taskIdsByInfoHash – Maps each info-hash to the list of task IDs sharing that content.

These indexes prevent collisions when multiple tasks reference the same torrent or magnet link.

Step 4: Inspect the Filesystem for Output Artifacts

For every in-flight task, inspectFs checks whether the temporary output (diskPath) and the final output (finalPath) exist on disk【/src/core/task/task-recovery-service.ts#L27-L41】. It returns one of four FsState values:

  • temp_only
  • final_only
  • both
  • neither

This filesystem state is a critical input because it tells the service whether a download finished writing, is partially complete, or was already moved before the crash.

Step 5: Select a Matching Live Engine GID

The selectMatchingGid function uses the byInfoHash map to locate a live engine GID that belongs to the same content【/src/core/task/task-recovery-service.ts#L93-L125】. The algorithm prefers a persisted GID that the task already owns. If that is unavailable, it may adopt a unique GID only when the info-hash maps to exactly one active task and no other Motrix task currently owns that GID. This heuristic prevents one task from stealing another's live engine session.

Step 6: Determine the Recovery Action

All prior inputs feed into the pure decision function determineAction, which returns a RecoveryAction based on the task's TransitionPhase, FsState, whether a matching GID was found, and the task type【/src/core/task/task-recovery-service.ts#L53-L84】. The possible actions include:

  • ResumeFromRename – Move temporary data to its final destination.
  • ResumeFromReseed – Finalize a torrent that is still seeding elsewhere.
  • AdoptExistingGid – Take over a live engine session.
  • MarkCompleted – Treat the task as already finished.
  • MarkError – Flag a filesystem inconsistency.
  • NoOp – Skip tasks that are already idle.

Because determineAction is pure, it is easily unit-tested under every combination of phase and filesystem state.

Step 7: Apply the Chosen Action

applyAction executes the concrete side effects for each RecoveryAction returned by the decision engine【/src/core/task/task-recovery-service.ts#L150-L164】. The implementation uses a TypeScript switch over the action enum:

private async applyAction(
  task: DownloadTask,
  action: RecoveryAction,
  fsState: FsState,
  matchingGid: string | undefined,
  report: RecoveryReport
): Promise<void> {
  switch (action) {
    case RecoveryAction.ResumeFromRename:
      await this.deps.fs.renameAtomic(task.diskPath, task.finalPath)
      // …persist and report
      break
    case RecoveryAction.AdoptExistingGid:
      task.engineTaskId = matchingGid!
      // …set status to Seeding, persist, and add warning
      break
    // other cases omitted for brevity
  }
}

ResumeFromRename

When a task was in the Renaming phase and only the temporary file exists, the service moves the file atomically from task.diskPath to task.finalPath. For media tasks this performs the rename; for other types it may finalize the task directly【/src/core/task/task-recovery-service.ts#L51-L58】.

ResumeFromReseed

If the final output already exists but the engine still has a matching info-hash, the service records completion, resets the transition phase to Reseeding, persists the task, and then finalizes it【/src/core/task/task-recovery-service.ts#L59-L66】.

AdoptExistingGid

When the filesystem shows final_only and a live engine GID matches, the task adopts that GID via task.engineTaskId = matchingGid, transitions to Idle and Seeding, persists the updated state, and records a warning in report.warnings【/src/core/task/task-recovery-service.ts#L67-L85】.

MarkCompleted

If the final file exists and no live engine match is found, the task is treated as already finished. The service applies a completed status, persists the transition, and fires the afterComplete hook【/src/core/task/task-recovery-service.ts#L86-L95】.

MarkError

A filesystem state of both—where temporary and final outputs coexist—indicates an inconsistency. The service moves the task to the Error state with the diagnostic code TaskRecoveryFsMismatch. If available, it also triggers applyDiagnosisUpgrade via the database to record extended diagnostics【/src/core/task/task-recovery-service.ts#L96-L123】.

NoOp

Tasks in the Idle phase with no filesystem anomalies receive NoOp and are left untouched.

Step 8: Persist and Log the Recovery Report

After all tasks are processed, the service aggregates report.recovered, report.warnings, and report.errors, calculates the total duration, and emits a structured log【/src/core/task/task-recovery-service.ts#L254-L265】. The RecoveryReport is then returned to the caller, which in Motrix is triggered from the main process entry point in src/main/index.ts.

How TaskRecoveryService Is Instantiated

In src/main/index.ts, the application instantiates TaskRecoveryServiceImpl during startup and passes it the TaskManager, active engine adapter, filesystem inspector (defaultRecoveryFs), and an activityRecorder. When the renderer process requests recovery, the IPC command in src/server/ipc/commands.ts forwards the call to the supervisor, keeping the heavy logic in the main process.

// Simplified startup pattern from src/main/index.ts
import { TaskRecoveryServiceImpl, defaultRecoveryFs } from '@core/task/task-recovery-service'

const recoveryService = new TaskRecoveryServiceImpl({
  taskManager,
  adapter,
  fs: defaultRecoveryFs,
  activityRecorder,
  finalizeTask,
  log: console
})

const report = await recoveryService.recoverOnStartup()
console.info('Recovery finished', report)

Decision Logic Example

The core of the recovery algorithm is deterministic. Here is the determineAction excerpt that maps phase and filesystem state to an action:

export function determineAction(input: RecoveryInput): RecoveryAction {
  const { phase, fsState, aria2HasMatchingInfoHash, taskType } = input

  if (phase === TransitionPhase.Idle) return RecoveryAction.NoOp

  if (phase === TransitionPhase.Renaming) {
    if (fsState === 'temp_only') return RecoveryAction.ResumeFromRename
    if (fsState === 'final_only')
      return isTorrentLikeType(taskType)
        ? RecoveryAction.ResumeFromReseed
        : RecoveryAction.MarkCompleted
    return RecoveryAction.MarkError
  }

  if (phase === TransitionPhase.Reseeding) {
    if (fsState === 'final_only')
      return aria2HasMatchingInfoHash
        ? RecoveryAction.AdoptExistingGid
        : RecoveryAction.ResumeFromReseed
    if (fsState === 'temp_only') return RecoveryAction.ResumeFromRename
    return RecoveryAction.MarkError
  }

  return RecoveryAction.NoOp
}

Summary

  • recoverOnStartup filters all non-idle or Finalizing tasks from the TaskManager and clones them for safe inspection.
  • The engine's active and waiting rows are mapped by info-hash so Motrix can adopt live GIDs when appropriate.
  • inspectFs returns temp_only, final_only, both, or neither, which drives the recovery decision.
  • determineAction is a pure function that selects one of six RecoveryActions based on phase, filesystem state, and engine presence.
  • applyAction handles atomic renames, live GID adoption, completion hooks, and error diagnostics including TaskRecoveryFsMismatch.
  • Results are aggregated into a RecoveryReport with counts for recovered tasks, warnings, and errors.

Frequently Asked Questions

What happens if a task was in the Renaming phase during a crash?

If the filesystem shows temp_only, determineAction returns ResumeFromRename and applyAction moves the temporary file to task.finalPath. If the final file already exists and the task is torrent-like, it may instead receive ResumeFromReseed. Any inconsistency results in MarkError.

How does Motrix prevent duplicate downloads after recovery?

The service indexes owned GIDs in ownerByGid and only allows AdoptExistingGid when the info-hash maps to exactly one active engine task and no other Motrix task owns that GID【/src/core/task/task-recovery-service.ts#L93-L125】. This ensures a one-to-one mapping between a recovered task and a live engine session.

What does the MarkError recovery action indicate?

MarkError is triggered when the filesystem check returns both, meaning temporary and final output paths exist simultaneously. The task is moved to the Error state with the code TaskRecoveryFsMismatch, and the database may store extended diagnostics via applyDiagnosisUpgrade【/src/core/task/task-recovery-service.ts#L96-L123】.

Where is TaskRecoveryServiceImpl initialized in the codebase?

TaskRecoveryServiceImpl is constructed in src/main/index.ts during application startup. The renderer process can trigger recovery through an IPC command defined in src/server/ipc/commands.ts, which delegates to the supervisor running in the main process.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →