Fix the 404 when uploading a recorded dataset to the Hub#84
Merged
Conversation
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.
There was a problem hiding this comment.
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 thisfinallyimmediately and both device disconnects are skipped. Nest finalization in its owntry/finallyso 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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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()writesmeta/episodes/and the parquet footers.record_with_web_eventsnever 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. Butrecording_activeflips toFalsebefore the worker frame dies, andfinalize()itself blocks on video and image flushing, so the frontend reaches/dataset-infoand/upload-datasetwhile the dataset is still being written. Small recordings healed before anyone noticed; camera recordings did not.Recording with
push_to_hub=Truewas worse: it pushed before finalize, so it uploaded a dataset with a truncated data parquet rather than 404ing.Reproduced deterministically:
Repairing datasets already on disk
The fix is forward-only, so
lelab/dataset_repair.pyrebuilds the episode index of a recording that was interrupted: episode boundaries and frame ranges from the data parquet, tasks fromtasks.parquet, video windows from cumulative durations probed with PyAV. It runs before everyLeRobotDataset(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.jsoncounters 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:
robot.connect()ends withconfigure(), which runs insidebus.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 oncerecord_loopstarted reading it.Upstream writes the calibration before
configure(), andteleoperate.pyalready 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 throughbus.connect()also sidestepsconnect()'s stdin prompt, which raisedEOFErrorunder 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'sinit_logging(), called fromlelab/calibrate.py, installs a root formatter that returns onlyrecord.getMessage(), droppingexc_infoprocess-wide. The recording error handler now folds the traceback into the message,/recording-statuscarries 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 checkandruff formatclean.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.