Skip to content

fix: honor rclone exit codes in the storage engines - #2912

Open
greglum wants to merge 3 commits into
devfrom
fix/rclone-exit-code
Open

greglum wants to merge 3 commits into
devfrom
fix/rclone-exit-code

Conversation

@greglum

@greglum greglum commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

Every rclone invocation in the storage engines ran through three process helpers in AbstractRCloneStorageEngine that called p.waitFor() and discarded the exit code. A failed transfer returned normally, so PushToStorage answered true after a copy that never happened. Listings against the same engine succeed, which makes the false success look like a working pipeline.

The same lines held a second defect: waitFor() ran before either pipe was drained, with stdout and stderr both on Redirect.PIPE. Once a child filled the OS pipe buffer (~64KB) it blocked on write and waitFor() never returned, leaking the calling thread. lsjson --metadata on a large container reaches this.

Changes Made

  • Capture the exit code on every rclone invocation and throw IOException on failure (IOException was already declared on every signature, so no caller changes).
  • Policy: transfers and config create fail on any non-zero exit. lsf/lsjson and the deletes (delete and purge) tolerate 3 (directory not found) and 4 (file not found), where a missing path is an empty result / no-op rather than an error.
  • Drain both pipes concurrently while the process runs (stderr on its own thread), then waitFor() - removes the pipe-buffer deadlock.
  • Failure messages name only the rclone sub-command plus capped stderr, and drop stderr entirely for config commands - the config argv carries storage credentials and the message travels back to the caller.
  • deleteRcloneConfig logs instead of throwing: every caller runs it from a finally block, so a cleanup failure was replacing the real transfer failure being unwound.
  • All nine storage reactors (PushToStorage, PullFromStorage, both syncs, DeleteFromStorage, both lists, GetStorageFileAsBase64, UpdateStorageFileMetadata) append the exception message to their thrown error instead of a fixed string, matching the idiom used elsewhere. The metadata one also stops hiding the UnsupportedOperationException that every non-native engine throws.
  • Unit tests for the runner (exit-code enforcement, tolerated codes, deadlock regression with a preemptive timeout, credential-redaction) plus an end-to-end test driving S3StorageEngine.copyToStorage() against a stub rclone whose copy fails.

How to Test

  1. mvn test -Dtest=AbstractRCloneStorageEngineUnitTests - all six pass; against the previous code the exit-code test fails ("Expected IOException... nothing was thrown") and the deadlock test times out.
  2. Manual: point any rclone-backed storage engine at a bucket the credentials cannot write to and run PushToStorage(...). Before: returns true, file absent. After: pixel errors with rclone copy failed with exit code N - <reason>.
  3. Browse a storage path that does not exist (ListStoragePath) - still returns an empty list, unchanged.

Notes

  • Behavior change: operations that silently swallowed failures now throw. Flows built on CentralCloudStorage (engine push/pull, image push) will surface rclone failures instead of continuing with missing files - that is the intent, but reviewers should confirm no flow depends on the old silent behavior.
  • rclone's contract (https://rclone.org/docs/#exit-code): non-zero only after its own retries are exhausted; 3/4 are the not-found codes tolerated on reads and deletes.
  • Known limitation, unchanged from before: there is still no overall process timeout, so a network-stalled rclone blocks the caller. Separate concern if wanted.
  • streamJsonOutput/streamListJsonOutput/streamError (protected statics orphaned by the rework) were removed, along with runProcessJsonOutput and the no-tolerance runProcessListJsonOutput overload, which had no callers even before this change; no references exist in this repo or Monolith.

@snyk-io

snyk-io Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@greglum
greglum force-pushed the fix/rclone-exit-code branch 2 times, most recently from b24f09e to b7f38fc Compare August 20, 2026 01:36
The three process runners in AbstractRCloneStorageEngine called
p.waitFor() and discarded the exit code, so every rclone invocation
reported success no matter what rclone did. A failed copy or sync
returned normally and PushToStorageReactor returned true on it.

Capture the exit code and fail on it. Reads (lsf, lsjson) and deletes
tolerate 3 and 4, where a missing path is an empty result or an
already finished delete rather than an error; everything else fails
on any non-zero exit, which rclone only returns once it has given up
retrying.

Two things fall out of the same six lines:

- waitFor() ran before either pipe was drained. A child that filled
  the OS pipe buffer blocked on write and waitFor() never returned,
  hanging the calling thread. lsjson --metadata on a large container
  reaches this. Both pipes are now drained while the process runs,
  stderr on its own thread.
- The failure message names only the rclone sub-command, never the
  arguments, and drops process error output for config calls. A
  config create carries the storage credentials in its argv and the
  message travels back to the caller.

deleteRcloneConfig no longer propagates a cleanup failure. Every
caller runs it from a finally block, so throwing there replaced the
transfer failure being unwound with a misleading one.
The storage reactors caught every failure and rethrew a fixed string,
so the reason rclone gave up only ever reached the server log. Append
the exception message the way the other reactors do. The engine builds
that message from the rclone sub-command and capped stderr and never
includes the command arguments, so no credentials can travel with it.

Covers push, pull, both syncs, delete, both lists, get-as-base64, and
update-metadata. The metadata one also stops hiding the
UnsupportedOperationException that every non-native engine throws.
@greglum
greglum marked this pull request as ready for review August 20, 2026 02:26
@greglum
greglum force-pushed the fix/rclone-exit-code branch from b7f38fc to 525970a Compare August 20, 2026 02:26
@greglum
greglum requested a review from a team as a code owner August 20, 2026 02:26
runProcessJsonOutput, the no-tolerance runProcessListJsonOutput
overload, and parseJsonOutput have no callers in this repo or
Monolith - the map-shaped runner was dead before the rework as
well. Same justification as the streamed variants already removed:
keeping them means reworking and maintaining API surface nobody
uses.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant