Skip to content

Fix the 404 when uploading a recorded dataset to the Hub#84

Merged
nicolas-rabault merged 3 commits into
mainfrom
fix/recording-dataset-finalize
Jul 23, 2026
Merged

Fix the 404 when uploading a recorded dataset to the Hub#84
nicolas-rabault merged 3 commits into
mainfrom
fix/recording-dataset-finalize

Conversation

@nicolas-rabault

Copy link
Copy Markdown
Member

Users reported a 404 when pushing a recorded dataset to the Hub. Two separate bugs turned up: the one behind the report, and one that surfaced while verifying the fix on hardware.

The 404

LeRobotDataset.finalize() writes meta/episodes/ and the parquet footers. record_with_web_events never called it, so a finished recording left a directory with no episode index. LeRobot reads that as "not downloaded yet", asks the Hub for the dataset's refs, and 404s because it was never pushed.

It looked intermittent because DatasetWriter.__del__ finalizes as a safety net. But recording_active flips to False before the worker frame dies, and finalize() itself blocks on video and image flushing, so the frontend reaches /dataset-info and /upload-dataset while the dataset is still being written. Small recordings healed before anyone noticed; camera recordings did not.

Recording with push_to_hub=True was worse: it pushed before finalize, so it uploaded a dataset with a truncated data parquet rather than 404ing.

Reproduced deterministically:

### without finalize
on-disk meta/: ['info.json', 'stats.json', 'tasks.parquet']
RESULT: RepositoryNotFoundError 404 Client Error.

### with finalize
on-disk meta/: ['file-000.parquet', 'info.json', 'stats.json', 'tasks.parquet']
RESULT: loaded OK, episodes = 1

Repairing datasets already on disk

The fix is forward-only, so lelab/dataset_repair.py rebuilds the episode index of a recording that was interrupted: episode boundaries and frame ranges from the data parquet, tasks from tasks.parquet, video windows from cumulative durations probed with PyAV. It runs before every LeRobotDataset(repo_id) on a recorded dataset and is a no-op when the index already loads.

Recovery is bounded by the parquet footer. Only the newest data file is ever left open, so recovery stops there; trailing unreadable files are renamed out of the reader's glob and info.json counters are rewritten to match what survived. A killed short session recovers nothing, because LeRobot rotates data files at 100 MB and below that there is a single open file whose footer was never written. Those now get an explicit "re-record it" message instead of a Hub 404. Long sessions recover everything up to the last rotation.

Verified against a SIGKILLed recording: 4 of 6 episodes recovered, frames decode, video stays aligned to the right episode (worst pixel deviation 2/255, which is codec noise), action data exact, second call a no-op.

The second bug

With the upload path fixed, recording on real arms died a couple of seconds into the loop:

Failed to sync read 'Present_Position' on ids=[1, 2, 3, 4, 5, 6] after 1 tries.
[TxRxResult] There is no status packet!

robot.connect() ends with configure(), which runs inside bus.torque_disabled() and so re-enables torque on exit. Recording then wrote the calibration registers (Homing_Offset, Min_Position_Limit, Max_Position_Limit) with torque on and no guard of its own, which left the bus unable to answer once record_loop started reading it.

Upstream writes the calibration before configure(), and teleoperate.py already did the same. That is the discriminator: teleoperation worked, recording did not. Recording now follows the teleoperation sequence, connect the buses, write the calibration, connect the cameras, configure the motors. Going through bus.connect() also sidesteps connect()'s stdin prompt, which raised EOFError under the server whenever the motors disagreed with the calibration file.

Why this was hard to see

logger.exception("Recording session failed") printed no traceback. lerobot's init_logging(), called from lelab/calibrate.py, installs a root formatter that returns only record.getMessage(), dropping exc_info process-wide. The recording error handler now folds the traceback into the message, /recording-status carries the failure reason, and the frontend shows it instead of sending the user to the upload page for a dataset that was never recorded.

Testing

197 tests pass, 9 new ones covering the repair (rebuild with and without video, idempotency, truncated file quarantine, unrecoverable case, no-op on healthy and absent datasets). ruff check and ruff format clean.

Verified by hand on SO-101 hardware for the connect ordering and the recording loop. The end to end record then upload run is worth repeating by a reviewer with arms attached.

Note for reviewers

One thing this does not address: the log showed Record loop is running slower (14.8 Hz) than the target FPS (30 Hz) with two 640x480@30 cameras. Unrelated to these bugs, but it will drop frames.

LeRobotDataset.finalize() is what writes meta/episodes/ and the parquet
footers. record_with_web_events never called it, so a finished recording
left a directory with no episode index. LeRobot reads that as "not
downloaded yet" and fetches the dataset from the Hub, which 404s for a
dataset that was never pushed.

The DatasetWriter destructor finalizes as a safety net, which is why the
failure looked intermittent: recording_active flips to False before the
worker frame dies, so the frontend can reach /dataset-info and
/upload-dataset while the dataset is still being written. Finalizing in
the recording teardown closes that window.

Datasets already left in that state are handled by a new dataset_repair
module that rebuilds meta/episodes/ from the data files: episode
boundaries and frame ranges from the parquet, tasks from tasks.parquet,
video windows from cumulative durations probed with PyAV. Recovery stops
at the first data file without a parquet footer, since only the newest
file is ever left open; trailing unreadable files are renamed out of the
reader's glob and info.json counters are rewritten to match what
survived. When nothing is recoverable the handlers say so instead of
letting a Hub 404 surface.

Recording with push_to_hub now also skips the push when no episode was
saved, matching upstream.
Recording died a couple of seconds into the loop with "Failed to sync
read 'Present_Position' on ids=[1, 2, 3, 4, 5, 6] after 1 tries.
[TxRxResult] There is no status packet!".

robot.connect() ends with configure(), which runs inside
bus.torque_disabled() and therefore re-enables torque on exit. Recording
then wrote the calibration registers (Homing_Offset, Min_Position_Limit,
Max_Position_Limit) with torque on and no guard of its own, leaving the
bus unable to answer once record_loop started reading it. Upstream
writes the calibration before configure(), and teleoperate.py already
did the same, which is why teleoperation worked and recording did not.

Recording now follows the teleoperation sequence: connect the buses,
write the calibration, connect the cameras, configure the motors. Going
through bus.connect() also sidesteps connect()'s stdin prompt, which
raised EOFError under the server whenever the motors disagreed with the
calibration file.

None of this was visible in the logs: lerobot's init_logging() installs
a root formatter that renders only the message, dropping the traceback
logger.exception() attaches. The recording error handler now folds the
traceback into the message, /recording-status carries the failure
reason, and the frontend shows it instead of sending the user to the
upload page for a dataset that was never recorded.
Copilot AI review requested due to automatic review settings July 23, 2026 15:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes dataset upload 404s by finalizing recordings, repairing interrupted datasets, and surfacing recording failures.

Changes:

  • Finalizes datasets before upload and corrects hardware connection ordering.
  • Adds recovery for interrupted datasets with tests.
  • Displays backend recording errors in the frontend.

Reviewed changes

Copilot reviewed 5 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
lelab/record.py Finalizes recordings, repairs uploads, and improves device setup/errors.
lelab/dataset_repair.py Reconstructs interrupted dataset metadata.
tests/test_dataset_repair.py Tests repair and truncation scenarios.
frontend/src/pages/Recording.tsx Displays recording failures.
frontend/dist/index.html References rebuilt assets.
frontend/dist/assets/index-C5KQGL1C.css Rebuilt stylesheet.
CLAUDE.md Documents dataset repair.
Comments suppressed due to low confidence (1)

lelab/record.py:891

  • If finalize() fails while flushing video/images or closing parquet writers, execution leaves this finally immediately and both device disconnects are skipped. Nest finalization in its own try/finally so hardware resources are released even when dataset finalization raises.
        dataset.finalize()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lelab/dataset_repair.py Outdated
Comment thread lelab/record.py Outdated
Comment thread lelab/dataset_repair.py
Comment thread lelab/dataset_repair.py
Comment thread frontend/src/pages/Recording.tsx Outdated
…d data consistent

repair_local_dataset took repo_id straight from the request body and joined it
onto the cache root, so a value like ../../other could point the rewrite of
info.json and the renaming of parquet files outside the LeRobot cache. It now
resolves the target and rejects anything that is not inside the cache, matching
what handle_delete_dataset already does.

Device setup ran before the try whose finally disconnects, so a failure partway
through it (leader bus, cameras, configure) left the already-open follower bus
and cameras behind, busy for the next attempt. Setup now runs inside that try.
Finalization is nested in its own try/finally for the same reason: a finalize
that raises no longer skips the disconnects.

Dropping episodes the videos do not cover only trimmed the episode index, so
their frames stayed in the data shards. The reader ignores them, but they still
travelled with the dataset and got pushed to the Hub, and a probe confirmed the
gap: 20 frames indexed against 40 rows on disk. Those rows are now removed from
the shards.

stats.json is aggregated as each episode is saved, so it still described the
episodes that were dropped and training would have normalized with it. It is now
recomputed over the retained frames, per episode then aggregated, the same way
recording builds it, rather than computed in one pass; a single pass produces
exact quantiles that do not match the aggregated ones LeRobot writes. Checked
against a dataset that only ever had the retained episodes: numeric features
match exactly, video features to within 0.008, which is codec noise.

A failed session does not mean nothing was recorded, since episodes can be saved
before a failure in the reset phase, in finalize, or in the Hub push. The saved
count is captured before the teardown resets it and carried in the status, and
the frontend only returns home when that count is zero, otherwise it offers the
partial dataset for upload.
@nicolas-rabault
nicolas-rabault merged commit 308c7c3 into main Jul 23, 2026
6 checks passed
@nicolas-rabault
nicolas-rabault deleted the fix/recording-dataset-finalize branch July 23, 2026 15:46
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.

2 participants