Skip to content

fix(gentle): a corrupt Torque_Limit read must not raise from the cleanup#42

Merged
OriNachum merged 3 commits into
mainfrom
fix/gentle-torque-limit-restore
Jul 12, 2026
Merged

fix(gentle): a corrupt Torque_Limit read must not raise from the cleanup#42
OriNachum merged 3 commits into
mainfrom
fix/gentle-torque-limit-restore

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

Found on hardware, mid-probe, while mapping joint limits.

The bug

CliError: Torque limit 2048 is out of range; valid range is 0–1000
  ... raised from gentle_move's finally

read_torque_limit returned 2048 on a healthy motor whose register actually held 500.
The read succeeded — it just returned nonsense, and 2048 is outside the register's own
[0, 1000] band, so it cannot be a torque limit at all.

That value was stashed as the pre-move Torque_Limit. The finally then tried to write it
back, the servo's range check rejected it, and a CliError was raised out of the cleanup
masking whatever the move had actually been doing.

The finally suppressed only OverloadError, so a CliError sailed straight out of it.

Two things this is an instance of

A cleanup that can raise. Third time: safety._release_motor and profile._release were the
first two. The rule is now uniform — cleanup survives any BaseException (with SystemExit
re-raised), because a cleanup that throws replaces the failure the operator actually needs to see.

A read that succeeds and lies. Second time today: a read_position returned 0 immediately
after an EEPROM write while the servo genuinely held 3387. The bus layer retries reads that
fail; it has no way to know a successful one is garbage. A plausible-looking wrong value is
more dangerous than an error, because nothing downstream can tell it from a real reading.

The fix

  • Validate at the point of READ, not the point of write. _sane_torque_limit() returns None
    for anything outside [0, 1000]. If we do not know the pre-move value, we say so rather than
    restoring a fiction.
  • The finally cannot raise. With the pre-move value unknown, the conservative
    _CONTACT_TORQUE_LIMIT cap simply stays in place. A joint left slightly under-torqued is a
    nuisance; a cleanup that raises is a lie about why the move failed.

Tests

Two regression tests, both verified RED against the old code and GREEN against the fix:

  • the move still completes and reports where it ended, rather than dying in its own finally
  • 2048 never reaches the servo — only the cap is written

1244 tests pass (was 1242). black / isort / flake8 / bandit clean.

  • arm101-cli (Claude)

OriNachum and others added 2 commits July 12, 2026 17:15
…nup (v0.22.1)

Found on hardware while probing joint limits.

read_torque_limit returned 2048 on a healthy motor whose register actually held
500. The read SUCCEEDED — it just returned nonsense, and 2048 is outside the
register's own [0, 1000] range, so it cannot be a torque limit at all. The bus
layer retries FAILED reads; it has no way to know a successful one is garbage.

The value was stashed as the pre-move Torque_Limit. The finally then tried to
write it back, the servo's range check rejected it, and a CliError was raised OUT
OF THE CLEANUP — masking whatever the move had actually been doing. The finally
suppressed only OverloadError, so a CliError sailed straight out of it.

This is the third instance of the same trap (safety._release_motor and
profile._release were the first two), and the second instance of the deeper one: a
read that succeeds and lies. A plausible-looking wrong value is more dangerous than
an error, because nothing downstream can tell it from a real reading — exactly like
the read_position that returned 0 immediately after an EEPROM write.

Two fixes:

  - VALIDATE AT THE READ, not at the write. _sane_torque_limit() returns None for
    anything outside [0, 1000]. If we do not know the pre-move value we say so,
    rather than restoring a fiction.
  - The finally CANNOT RAISE. With the pre-move value unknown, the conservative
    _CONTACT_TORQUE_LIMIT cap simply stays in place. A joint left slightly
    under-torqued is a nuisance; a cleanup that raises is a lie about why the move
    failed.

Two regression tests, both proven RED against the old code and GREEN against the
fix: the move still reports itself, and 2048 never reaches the servo.

1244 tests pass (was 1242).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix gentle_move: ignore corrupt Torque_Limit reads and never raise from cleanup

🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Validate Torque_Limit at read-time and treat out-of-band values as unknown
• Make gentle_move’s restore path best-effort so finally never masks the real failure
• Add regression tests for corrupt reads and bump changelog/version to 0.22.1
Diagram

graph TD
  A["gentle_move()"] --> B["bus.read_torque_limit()"] --> C{Value in 0..1000?}
  C -- Yes --> D["original_torque_limit = value"] --> E["Write CONTACT cap"] --> F["Execute gentle motion"] --> G["finally: restore torque limit"]
  C -- No --> H["original_torque_limit = None"] --> E
  G --> I{original_torque_limit is None?}
  I -- No --> J["try write original; suppress BaseException"]
  I -- Yes --> K["Leave CONTACT cap in place"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Only suppress exceptions in finally (no read-time validation)
  • ➕ Smallest change: make restore non-throwing and stop masking failures
  • ➕ No new helper/logic for torque-limit plausibility
  • ➖ Still treats corrupt reads as truth internally (until restore), making future logic riskier
  • ➖ Doesn’t explicitly encode the invariant that Torque_Limit must be within 0..1000
2. Bus-level consistency reads (double-read / majority-read) for critical registers
  • ➕ Addresses the deeper class of 'successful read that lies' more generally
  • ➕ Could protect other reads (e.g., position) where plausibility checks are weaker
  • ➖ Adds latency and complexity to the bus layer
  • ➖ Hard to choose generic policies/thresholds; may still fail in correlated-error scenarios
3. Centralize cleanup safety via a shared helper/context manager
  • ➕ Makes the 'cleanup must not raise (except SystemExit)' rule uniform and harder to regress
  • ➕ Reduces repeated try/except patterns across safety/profile/gentle
  • ➖ Requires a small refactor across modules; larger surface area for a hotfix release
  • ➖ Must be carefully designed to avoid swallowing actionable operator signals

Recommendation: The PR’s approach is the best near-term fix: validate Torque_Limit at read-time (so corrupt values never influence restore decisions) and ensure finally cannot raise and mask the real failure. Consider a follow-on to add a reusable 'safe cleanup' helper and/or targeted multi-read strategies for registers where plausibility bounds aren’t as strict as Torque_Limit.

Files changed (5) +114 / -3

Bug fix (1) +46 / -2
gentle.pySanity-check torque-limit reads and make restore best-effort +46/-2

Sanity-check torque-limit reads and make restore best-effort

• Introduces _sane_torque_limit() with a hard bound (0..1000) to treat out-of-range reads as corrupt/unknown. Updates gentle_move to use this on the pre-move read and changes the finally restore to suppress all BaseException (re-raising SystemExit) so cleanup cannot mask the primary failure.

arm101/hardware/gentle.py

Tests (1) +50 / -0
test_gentle.pyAdd regressions for corrupt Torque_Limit reads in gentle_move cleanup +50/-0

Add regressions for corrupt Torque_Limit reads in gentle_move cleanup

• Adds a fake bus that returns a successful-but-garbage torque limit (2048) and two tests: the move must still return a result (no exception from finally), and the garbage value must never be written back (only the contact cap is written).

tests/test_gentle.py

Documentation (2) +17 / -0
arm101-cli__public.jsonlAdd hardware-run note about seam hiding due to factory offset +1/-0

Add hardware-run note about seam hiding due to factory offset

• Appends a new public memory entry documenting a hardware finding: the software bound can land on the seam for factory-offset joints, hiding additional travel across the wrap. This is contextual documentation for ongoing joint-limit mapping work.

.eidetic/memory/arm101-cli__public.jsonl

CHANGELOG.mdDocument 0.22.1 fix for corrupt Torque_Limit read masking failures +16/-0

Document 0.22.1 fix for corrupt Torque_Limit read masking failures

• Adds a 0.22.1 release entry describing the failure mode (garbage Torque_Limit read leading to CliError from finally) and the behavioral change (validate on read; restore cannot raise).

CHANGELOG.md

Other (1) +1 / -1
pyproject.tomlBump version to 0.22.1 +1/-1

Bump version to 0.22.1

• Updates project version from 0.22.0 to 0.22.1 to ship the hotfix.

pyproject.toml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 8 rules
✅ Skills: cicd

Grey Divider


Informational

1. Silent torque restore failure 🐞 Bug ◔ Observability
Description
In gentle_move()'s finally, all exceptions from restoring Torque_Limit are swallowed, so a real
bus/comms failure can leave the motor capped (or in an unknown state) while gentle_move still
returns a normal result with no indication the restore failed or was skipped due to a corrupt read.
Code

arm101/hardware/gentle.py[R877-883]

        if original_torque_limit is not None:
-            with contextlib.suppress(OverloadError):
+            try:
                bus.write_torque_limit(motor, original_torque_limit)
+            except SystemExit:
+                raise
+            except BaseException:  # noqa: B036 - cleanup must outlive any bus failure
+                pass
Relevance

⭐ Low

Team prefers cleanup suppressing failures; best-effort BaseException suppression accepted to avoid
masking real errors (PRs #38,#39,#32).

PR-#38
PR-#39
PR-#32

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The restore path suppresses all failures, but the underlying bus API explicitly raises on write
failures; and the CLI surfaces the returned move dict verbatim, so without a new status field the
operator has no way to notice the restore did not occur.

arm101/hardware/gentle.py[792-907]
arm101/hardware/bus.py[1547-1588]
arm101/cli/_commands/arm.py[610-662]
arm101/hardware/safety.py[116-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`gentle_move()` intentionally prevents its cleanup from raising, but it currently provides **no signal** when torque-limit restoration was skipped (`original_torque_limit is None` due to a corrupt/out-of-range read) or when the restore write failed (any exception swallowed in the `finally`). This can leave the servo at `_CONTACT_TORQUE_LIMIT` while the CLI/operator sees an otherwise “successful” move result.

### Issue Context
- `MotorBus.write_torque_limit()` can fail with `CliError(EXIT_ENV_ERROR)` on comms/write failure.
- The CLI prints every key/value in the returned `gentle_move()` dict, so adding a status field is a straightforward way to make this visible without reintroducing cleanup-raised masking.

### Fix Focus Areas
- arm101/hardware/gentle.py[792-907]
- arm101/hardware/bus.py[1547-1588]
- arm101/cli/_commands/arm.py[610-662]

### Suggested approach
- Track and expose restore outcomes in the result dict, e.g.:
 - `"torque_limit_original": int | None`
 - `"torque_limit_restored": bool` (or `"torque_limit_restore_error": str | None`)
 - `"torque_limit_restore_skipped": bool` when the pre-move read was out-of-band
- Keep the “never raise from cleanup” rule, but capture the exception text (like safety does) for diagnostics instead of `pass`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

…ge 63.6% -> gate)

SonarCloud failed the gate on new-code coverage (63.6% vs 80%) with ZERO issues —
and it was right to. The uncovered lines were the except branches of the restore:
the exact safety property this PR adds. Shipping 'the cleanup can no longer raise'
without a test that MAKES it raise is shipping a claim, not a guarantee.

Two tests, mirroring the asymmetry:

  - a bus that accepts the CAP but fails the RESTORE (the normal case — the restore
    runs against a bus that may have just died, which is the whole reason it exists):
    the move still completes and reports where it ended.
  - a restore that raises SystemExit: it PROPAGATES. Everything else from the cleanup
    is swallowed — a dead bus, a corrupt packet, a second Ctrl-C — but an explicit
    request for the interpreter to exit is never this module's to override. Same rule
    as safety._release_motor, now pinned so it reads as a choice rather than an
    oversight.

gentle.py coverage 96% -> 98%. 1246 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@sonarqubecloud

Copy link
Copy Markdown

@OriNachum OriNachum merged commit c05bda2 into main Jul 12, 2026
8 checks passed
@OriNachum OriNachum deleted the fix/gentle-torque-limit-restore branch July 12, 2026 15:24
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