Skip to content

Migrate I2C controller operations to async - #3156

Open
floitsch wants to merge 5 commits into
floitsch/i2c.20-register-targetfrom
floitsch/i2c.30-async-controller
Open

Migrate I2C controller operations to async#3156
floitsch wants to merge 5 commits into
floitsch/i2c.20-register-targetfrom
floitsch/i2c.30-async-controller

Conversation

@floitsch

@floitsch floitsch commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

  • Migrate controller probe, read, write, and write-read operations to the asynchronous ESP-IDF driver while preserving the synchronous-looking Toit API.
  • Serialize complete controller transactions per bus while keeping native primitive calls nonblocking.
  • Copy moving-heap data into per-operation native buffers and release it after completion or synchronous abort.
  • Abort a native transaction when the waiting Toit task is canceled, reset and clear the physical bus, and discard completion notifications that lost a race with cancellation.
  • Expose per-device clock-stretch timeout and ACK-check policy, preserve 7-bit/10-bit addressing, and return explicit I2C_NACK / I2C_TIMEOUT errors.
  • Route synchronous ESP_ERR_NO_MEM through the existing retryable OOM path after cleaning all partial state; callbacks allocate nothing and cannot report OOM.
  • Keep Toit I2C callbacks in IRAM only when CONFIG_I2C_ISR_IRAM_SAFE is enabled, allowing the original ESP32 image to fit.

Dependencies

Testing

  • Host build.
  • ESP32 and ESP32-C6 firmware compilation and linking.
  • toit analyze for both two-device hardware test programs.
  • Existing two-device coverage includes present/missing probes and scan, NACK and post-NACK recovery, ACK-check disable, 7-bit/10-bit device identity, 1–1024-byte transfers, FIFO/command-boundary sizes, concurrent task serialization, and resource reuse.
  • Added a hardware regression that cancels during clock stretching and then reuses the bus; this new hardware case has not yet been run on boards.
  • git diff --check in both repositories.

Stack created with GitHub Stacks CLIGive Feedback 💬

@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from 4c31bd3 to f3cf340 Compare August 10, 2026 12:46
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from f3cf340 to ce68a9f Compare August 10, 2026 12:50
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from ce68a9f to 5303ce1 Compare August 10, 2026 14:29
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch 2 times, most recently from 66a3170 to dc12d52 Compare August 10, 2026 17:59
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch 2 times, most recently from 48de4dd to b979f55 Compare August 10, 2026 21:40
floitsch added a commit to toitware/esp-idf that referenced this pull request Aug 10, 2026
## Summary

- Make asynchronous controller callbacks identify the actual transaction
device rather than searching by raw address.
- Apply per-device ACK policy and retain NACK/timeout status until the
transaction is terminal, producing exactly one accurate completion
callback.
- Delay normal completion until every command batch has been sent, clear
stale interrupt state before a new transfer, and mask terminal timeout
interrupts.
- Recover after NACK/timeout with a bounded FSM reset and return
`ESP_ERR_INVALID_STATE` for an unexpectedly busy bus instead of entering
synchronous bus-clear logic.

## Dependencies

- Builds on #122.
- Required by toitlang/toit#3156.

## Testing

Exercised by Toit's two-board asynchronous controller suite on ESP32 and
ESP32-S3: probes, scan, NACK and timeout recovery, ACK policy,
7-bit/10-bit identity, FIFO/command boundaries, concurrent callers, RMT
timing probes, and clock stretching.

The same callback and recovery gaps are still present in the inspected
ESP-IDF v6.1-dev checkout, so this patch remains a focused delta from
the current upstream API.

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from b979f55 to 8ebebe5 Compare August 10, 2026 21:51
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from 8ebebe5 to 6b39845 Compare August 11, 2026 01:18
@floitsch

Copy link
Copy Markdown
Member Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR changes ESP32 I2C operations to asynchronous native transactions with event-based completion. Toit APIs now serialize operations, support timeouts and ACK configuration, map native results, and coordinate shutdown. New board tests cover the controller behavior.

Changes

Asynchronous I2C controller

Layer / File(s) Summary
Event-driven I2C resources
src/resources/i2c_esp32.cc
I2C resources now use event queues, completion state, callback registration, operation tracking, and coordinated native cleanup.
Native completion operations
src/compiler/propagation/type_primitive_i2c.cc, src/primitive.h, src/resources/i2c_esp32.cc, lib/i2c.toit, third_party/esp-idf
The native interface replaces bus_reset with probe and transfer completion primitives. Probe, write, read, and combined operations now dispatch asynchronously.
Synchronized Toit API
lib/i2c.toit
Bus and device operations now suspend tasks, serialize access with a mutex, validate timeout and ACK settings, map controller results, and coordinate shutdown.
Hardware integration validation
tests/hw/esp32/i2c-async-controller-shared.toit, tests/hw/esp32/i2c-async-controller-board1.toit, tests/hw/esp32/i2c-async-controller-board2.toit
New ESP32 tests cover probing, transfer modes, address handling, timing, ACK behavior, concurrency, timeouts, target control, and cleanup.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: migrating I2C controller operations to asynchronous execution.
Description check ✅ Passed The description directly explains the asynchronous I2C migration, API behavior, resource handling, dependencies, and testing.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch floitsch/i2c.30-async-controller

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 7

🧹 Nitpick comments (2)
tests/hw/esp32/i2c-async-controller-shared.toit (1)

126-138: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The quartile split does not separate the two frequencies.

low-periods holds SCL low periods from the 50 kHz transfer and the 400 kHz transfer in one list. The code sorts the list and then reads the lower quartile as fast-low and the upper quartile as slow-low. This works only if the two transfers contribute similar sample counts and if no idle or stretched period lands in either quartile. The slow.write-read #[0] 8 transfer and the fast.write-read #[0] 8 transfer produce the same number of clock pulses, so the assumption holds today, but any change of transfer size makes the thresholds silently wrong.

Capture the two transfers in separate reads, or compute the medians of the two halves that are split by the long gap between transfers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/hw/esp32/i2c-async-controller-shared.toit` around lines 126 - 138,
Update the frequency probe logic around low-period collection so the 50 kHz and
400 kHz transfer samples are measured separately rather than relying on
quartiles from the combined low-periods list. Preserve the existing slow-low and
fast-low assertions by assigning each value from its corresponding transfer, and
remove the assumption that both transfers contribute equal sample counts.
lib/i2c.toit (1)

474-487: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Check the Toitdoc reference and the duplicated default value.

Two small points:

  1. Line 475 references $(device i2c-address --frequency --address-size). The primary overload now also declares --timeout-us and --disable-ack-check. Confirm that the Toitdoc resolver still matches this overload, otherwise the reference breaks and the analyzer can report an unresolved reference.
  2. The default --timeout-us/int=100_000 is repeated in both overloads (Line 458 and Line 480). If one default changes, the two overloads diverge silently. Consider a named constant, for example DEFAULT-TIMEOUT-US.
#!/bin/bash
# Look for existing toitdoc references to overloaded methods with partial named-argument lists.
rg -n '\$\(device ' lib/
rg -n 'DEFAULT-.*-US|DEFAULT_.*_US' lib/i2c.toit
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/i2c.toit` around lines 474 - 487, Update the Toitdoc reference for
i2c-address so it resolves unambiguously to the overload that includes
--frequency, --address-size, --timeout-us, and --disable-ack-check. Introduce a
shared DEFAULT-TIMEOUT-US constant and use it for the --timeout-us defaults in
both i2c-address overloads, preserving the current default behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/i2c.toit`:
- Around line 387-398: Update perform-controller-operation_ so the completion
wait is bounded rather than unconditionally blocking inside critical-do; derive
the deadline from the configured device timeout plus a watchdog margin, preserve
native-buffer lifetime until completion, and report I2C_TIMEOUT when
CONTROLLER-DONE-STATE_ is not signaled. Ensure timeout handling releases mutex_
so subsequent operations and close can proceed.
- Around line 433-441: In lib/i2c.toit lines 433-441, update Bus.close so its
mutex-protected teardown runs inside critical-do, and snapshot devices_.values
before clearing the map so Device.close-native_ cannot mutate devices_ during
iteration. In lib/i2c.toit lines 746-758, update the bus.mutex_ section around
close-native_ to use critical-do, ensuring all four state updates complete
atomically.

In `@src/resources/i2c_esp32.cc`:
- Around line 879-890: Update bus_probe and its Toit-layer call sites so the
temporary i2c_device_config_t uses the bus’s configured frequency instead of
hardcoded 100000. Propagate the existing bus frequency through the relevant API,
preserving the configured speed for both scan and test probing paths.
- Around line 1073-1076: Add the same closed-device guard used by device_write,
device_read, and device_write_read to device_write_finish, device_read_finish,
and device_write_read_finish. Check resource->handle() == null and return
ALREADY_CLOSED before calling resource->bus() or finish_controller_operation,
preventing null-bus dereferences.
- Around line 391-409: Update the callback-unregistration calls in the resource
cleanup paths, including the device-destruction flow and
I2cBusResource::finish_operation, to capture and validate the result of
i2c_master_register_event_callbacks before calling i2c_master_bus_rm_device or
freeing buffers. Handle ESP_ERR_INVALID_STATE according to the project’s
established error-handling pattern, and only continue destruction or buffer
cleanup after the callback state is safely handled.

In `@tests/hw/esp32/i2c-async-controller-shared.toit`:
- Line 111: Remove the redundant architecture guards in test-board1, eliminate
the return that exits before cleanup, and ensure the normal CLOSE handshake, bus
reuse check, and port.close execute before any destructive timeout path. Update
test-board2 so its CLOSE branch remains reachable and closes targets and the
UART normally; remove the duplicated bus reuse logic while preserving the
intended cleanup behavior.
- Around line 97-109: Remove the --unwind option from the catch expression
inside the task block so exceptions are stored in errors[task-index], allowing
done[task-index].set true to execute and the later errors.do propagation to
report failures without hanging.

---

Nitpick comments:
In `@lib/i2c.toit`:
- Around line 474-487: Update the Toitdoc reference for i2c-address so it
resolves unambiguously to the overload that includes --frequency,
--address-size, --timeout-us, and --disable-ack-check. Introduce a shared
DEFAULT-TIMEOUT-US constant and use it for the --timeout-us defaults in both
i2c-address overloads, preserving the current default behavior.

In `@tests/hw/esp32/i2c-async-controller-shared.toit`:
- Around line 126-138: Update the frequency probe logic around low-period
collection so the 50 kHz and 400 kHz transfer samples are measured separately
rather than relying on quartiles from the combined low-periods list. Preserve
the existing slow-low and fast-low assertions by assigning each value from its
corresponding transfer, and remove the assumption that both transfers contribute
equal sample counts.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 915e0bb1-7d91-47ad-97fd-3e519b6bc5ac

📥 Commits

Reviewing files that changed from the base of the PR and between 612d88d and 6b39845.

📒 Files selected for processing (8)
  • lib/i2c.toit
  • src/compiler/propagation/type_primitive_i2c.cc
  • src/primitive.h
  • src/resources/i2c_esp32.cc
  • tests/hw/esp32/i2c-async-controller-board1.toit
  • tests/hw/esp32/i2c-async-controller-board2.toit
  • tests/hw/esp32/i2c-async-controller-shared.toit
  • third_party/esp-idf

Comment thread lib/i2c.toit
Comment on lines +387 to +398
perform-controller-operation_ [start] [finish]:
return mutex_.do:
if not resource_: throw "CLOSED"
result := null
// Once dispatched, the native buffers must remain alive until finish.
critical-do --no-respect-deadline:
state_.clear-state CONTROLLER-DONE-STATE_
start.call
state_.wait-for-state CONTROLLER-DONE-STATE_
result = finish.call
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The completion wait is unbounded and non-cancelable while holding the bus mutex.

critical-do --no-respect-deadline makes state_.wait-for-state CONTROLLER-DONE-STATE_ immune to task cancellation and to any enclosing with-timeout. If the driver never delivers a completion event for a dispatched transaction, the calling task blocks permanently. The task also holds mutex_, so every other task that uses this bus blocks permanently, and close can never run.

The design relies on the hardware clock-stretch timeout (scl_wait_us) always producing I2C_EVENT_TIMEOUT. Any driver path that drops a queued transaction without a callback (for example a rejected queue insert) turns into a permanent bus deadlock.

Consider adding a bounded wait with a watchdog margin derived from the device timeout, and reporting I2C_TIMEOUT if the completion event does not arrive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/i2c.toit` around lines 387 - 398, Update perform-controller-operation_ so
the completion wait is bounded rather than unconditionally blocking inside
critical-do; derive the deadline from the configured device timeout plus a
watchdog margin, preserve native-buffer lifetime until completion, and report
I2C_TIMEOUT when CONTROLLER-DONE-STATE_ is not signaled. Ensure timeout handling
releases mutex_ so subsequent operations and close can proceed.

Comment thread lib/i2c.toit
Comment on lines 433 to +441
close -> none:
if not resource_: return
devices_.values.do: it.close
devices_.clear
i2c-bus-close_ resource_
resource_ = null
mutex_.do:
if not resource_: return
devices_.values.do: it.close-native_
devices_.clear
state_.dispose
i2c-bus-close_ resource_
resource_ = null
remove-finalizer this

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The two controller close methods are not cancellation-safe. Both perform multi-step teardown of native resources without critical-do. Target.close at Line 187 and RegisterTarget.close at Line 298 establish the expected pattern in this file. A cancellation in the middle of either sequence leaks the native handle, the reserved GPIO pins, or the map entry.

  • lib/i2c.toit#L433-L441: wrap the Bus.close body inside mutex_.do in critical-do, and snapshot devices_.values before devices_.clear so Device.close-native_ does not mutate the map during iteration.
  • lib/i2c.toit#L746-L758: wrap the bus.mutex_.do: close-native_ call in critical-do so the four state updates in close-native_ complete atomically.
📍 Affects 1 file
  • lib/i2c.toit#L433-L441 (this comment)
  • lib/i2c.toit#L746-L758
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/i2c.toit` around lines 433 - 441, In lib/i2c.toit lines 433-441, update
Bus.close so its mutex-protected teardown runs inside critical-do, and snapshot
devices_.values before clearing the map so Device.close-native_ cannot mutate
devices_ during iteration. In lib/i2c.toit lines 746-758, update the bus.mutex_
section around close-native_ to use critical-do, ensuring all four state updates
complete atomically.

Comment on lines +391 to +409
i2c_master_event_callbacks_t callbacks = {};
i2c_master_register_event_callbacks(device->handle(), &callbacks, null);
i2c_master_bus_rm_device(device->handle());
device->bus_ = null;
device->handle_ = null;
DeviceList::unlink(device);
}

void I2cBusResource::finish_operation() {
ASSERT(operation_in_flight_);
if (probe_handle_ != null) {
i2c_master_event_callbacks_t callbacks = {};
i2c_master_register_event_callbacks(probe_handle_, &callbacks, null);
i2c_master_bus_rm_device(probe_handle_);
}
free(tx_buffer_);
free(rx_buffer_);
cancel_prepared_operation();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ESP-IDF i2c_master_register_event_callbacks null on_trans_done unregister callback

💡 Result:

To unregister a previously registered callback in the ESP-IDF I2C driver, you must call the i2c_master_register_event_callbacks function and set the specific callback member within the i2c_master_event_callbacks_t structure to NULL [1][2][3]. For example, to unregister the on_trans_done callback, you would prepare a structure with that field set to NULL and pass it to the registration function: i2c_master_event_callbacks_t cbs = {.on_trans_done = NULL, }; ESP_ERROR_CHECK(i2c_master_register_event_callbacks(dev_handle, &cbs, NULL)); The official ESP-IDF documentation explicitly confirms this method for deregistering callbacks [2][4][5]. Setting the callback to NULL effectively disables the asynchronous behavior associated with that callback [6][2][7]. When no callback is registered, I2C transactions will typically default to synchronous (blocking) mode [6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- repository references ---'
rg -n -C 4 'i2c_master_register_event_callbacks|i2c_master_bus_rm_device|finish_operation|remove_device' src lib .github 2>/dev/null | head -240

printf '%s\n' '--- ESP-IDF version/configuration references ---'
rg -n -i 'esp-idf|idf_version|ESP_IDF|i2c_master_event_callbacks_t' . --glob '!node_modules/**' --glob '!build/**' --glob '!dist/**' 2>/dev/null | head -240

Repository: toitlang/toit

Length of output: 1912


🌐 Web query:

site:docs.espressif.com/projects/esp-idf i2c_master_register_event_callbacks esp_err_t unregister callback on_trans_done

💡 Result:

To unregister a previously registered event callback (such as on_trans_done) in the ESP-IDF I2C driver, you use the same function used for registration: i2c_master_register_event_callbacks [1][2]. Instead of passing a valid function pointer, you must set the specific callback member within the i2c_master_event_callbacks_t structure to NULL and pass that structure to the function [1][2][3]. For example, to unregister the on_trans_done callback: i2c_master_event_callbacks_t cbs = {.on_trans_done = NULL, }; esp_err_t err = i2c_master_register_event_callbacks(i2c_dev_handle, &cbs, NULL); Note that this function is designed to register or update callbacks; setting a member to NULL effectively removes the callback for that specific event [1][2]. Ensure that the i2c_master_event_callbacks_t structure is properly initialized before passing it to the registration function [1].

Citations:


🌐 Web query:

site:github.com/espressif/esp-idf "i2c_master_register_event_callbacks" "ESP_ERR_INVALID_STATE"

💡 Result:

In the ESP-IDF I2C driver, an ESP_ERR_INVALID_STATE error returned by i2c_master_register_event_callbacks generally indicates that the I2C master device handle provided is not in a valid state to register callbacks [1]. Based on the ESP-IDF driver implementation, this error typically occurs for the following reasons: 1. Improper Initialization: The I2C port or the specific device handle (i2c_master_dev_handle_t) may not have been correctly initialized or added to the bus before attempting to register callbacks [1]. 2. Driver Constraints: The API requires the device handle to be properly managed [1]. If the underlying bus or device state is invalid, the operation cannot proceed [1]. Although i2c_master_register_event_callbacks is specifically designed to enable asynchronous I2C transactions [2][3], it requires a valid and active device handle created via i2c_master_bus_add_device. Ensure that: - You have successfully created the bus handle and added the device to the bus. - You are passing the correct i2c_master_dev_handle_t to the function. - The I2C bus itself has not encountered a critical error or entered a faulted state that prevents further configuration [4][1]. For debugging, verify that your code successfully initializes the bus and the device before attempting to register callbacks, and check if any prior I2C calls returned an error. If you are attempting to use asynchronous features, also ensure you are not violating the constraint that only one device per bus can perform asynchronous operations at a time [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

for url in \
  'https://raw.githubusercontent.com/espressif/esp-idf/v5.4/components/esp_driver_i2c/i2c_master.c' \
  'https://raw.githubusercontent.com/espressif/esp-idf/v5.4/components/esp_driver_i2c/include/driver/i2c_master.h' \
  'https://raw.githubusercontent.com/espressif/esp-idf/v5.2.5/components/driver/i2c/i2c.c' \
  'https://raw.githubusercontent.com/espressif/esp-idf/v5.2.5/components/driver/i2c/include/driver/i2c_master.h'
do
  echo "--- $url ---"
  curl -LfsS "$url" | rg -n -C 12 'i2c_master_register_event_callbacks|register.*callback|unregister|ESP_ERR_INVALID_STATE' | head -220 || true
done

Repository: toitlang/toit

Length of output: 1912


🌐 Web query:

ESP-IDF v5.4 i2c_master_register_event_callbacks return ESP_ERR_INVALID_STATE callback already registered asynchronous transaction source

💡 Result:

The ESP_ERR_INVALID_STATE error when calling i2c_master_register_event_callbacks in ESP-IDF v5.4 typically indicates a violation of the I2C driver's asynchronous operation constraints [1][2]. Specifically, the driver documentation mandates that on any single I2C bus, only one device is permitted to perform asynchronous operations at a time [1][3][4]. If you receive this error, ensure the following conditions are met: 1. Asynchronous Constraint: Verify that no other device on the same I2C bus has already had callbacks registered for asynchronous transactions [1][4]. The driver tracks this state globally for the bus [5]. 2. Proper Deregistration: If you need to switch modes or re-register, you must first properly deregister the existing callbacks for the device. This is done by calling i2c_master_register_event_callbacks with the callback members in the i2c_master_event_callbacks_t structure set to NULL [1][6]. 3. Context Requirements: Ensure that your application logic is not attempting to mix synchronous and asynchronous modes on the same bus incorrectly. Some users have reported challenges when attempting to switch between these modes dynamically, often requiring careful management of the transaction queue and bus state [6]. If you are certain that no other device is using asynchronous mode and you have correctly attempted to clear the callbacks, verify that the I2C port and device handles are initialized correctly, as ESP_ERR_INVALID_STATE can also be returned if the underlying I2C hardware state is invalid (e.g., port not initialized) [2].

Citations:


Handle callback-unregistration errors before destroying the resource.

A zero-initialized i2c_master_event_callbacks_t clears on_trans_done, but i2c_master_register_event_callbacks can return ESP_ERR_INVALID_STATE. Check the result before removing the device or freeing buffers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resources/i2c_esp32.cc` around lines 391 - 409, Update the
callback-unregistration calls in the resource cleanup paths, including the
device-destruction flow and I2cBusResource::finish_operation, to capture and
validate the result of i2c_master_register_event_callbacks before calling
i2c_master_bus_rm_device or freeing buffers. Handle ESP_ERR_INVALID_STATE
according to the project’s established error-handling pattern, and only continue
destruction or buffer cleanup after the callback state is safely handled.

Comment on lines +879 to +890
i2c_device_config_t config = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
// The address is emitted explicitly below so its WRITE command can check
// the ACK. A START/address/STOP transaction generated by the regular
// device path does not attach ACK checking to the address-only transfer.
.device_address = I2C_DEVICE_ADDRESS_NOT_USED,
.scl_speed_hz = 100000,
.scl_wait_us = static_cast<uint32_t>(timeout_us),
.flags = {
.disable_ack_check = false,
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The probe frequency is hardcoded to 100 kHz.

bus_probe always creates the temporary device with .scl_speed_hz = 100000, and it ignores the frequency configured for the bus in lib/i2c.toit. A bus that operates at 400 kHz is therefore scanned at a different clock rate than the one used by real transfers. Targets that only respond correctly at the configured speed can produce inconsistent scan results.

Consider passing the bus frequency from the Toit layer to bus_probe, or document the fixed probe speed in the scan and test documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resources/i2c_esp32.cc` around lines 879 - 890, Update bus_probe and its
Toit-layer call sites so the temporary i2c_device_config_t uses the bus’s
configured frequency instead of hardcoded 100000. Propagate the existing bus
frequency through the relevant API, preserving the configured speed for both
scan and test probing paths.

Comment on lines +1073 to +1076
PRIMITIVE(device_write_finish) {
ARGS(I2cDeviceResource, resource);
return finish_controller_operation(resource->bus(), null, 0, process);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The finish primitives do not guard against a closed device.

device_write (Line 1055), device_read (Line 1080), and device_write_read (Line 1104) all check resource->handle() == null and return ALREADY_CLOSED. The three matching finish primitives do not perform any check. They call resource->bus() directly and pass the result to finish_controller_operation, which dereferences it with bus->operation_in_flight().

I2cBusResource::remove_device sets device->bus_ = null, so a device resource can outlive its bus and expose a null pointer here. The Toit layer currently prevents this order, but the native layer must not depend on that.

The same gap exists in device_read_finish (Lines 1095-1100) and device_write_read_finish (Lines 1125-1130).

🛡️ Proposed guard
 PRIMITIVE(device_write_finish) {
   ARGS(I2cDeviceResource, resource);
+  if (resource->handle() == null || resource->bus() == null) FAIL(ALREADY_CLOSED);
   return finish_controller_operation(resource->bus(), null, 0, process);
 }

Apply the same guard to device_read_finish and device_write_read_finish.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
PRIMITIVE(device_write_finish) {
ARGS(I2cDeviceResource, resource);
return finish_controller_operation(resource->bus(), null, 0, process);
}
PRIMITIVE(device_write_finish) {
ARGS(I2cDeviceResource, resource);
if (resource->handle() == null || resource->bus() == null) FAIL(ALREADY_CLOSED);
return finish_controller_operation(resource->bus(), null, 0, process);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resources/i2c_esp32.cc` around lines 1073 - 1076, Add the same
closed-device guard used by device_write, device_read, and device_write_read to
device_write_finish, device_read_finish, and device_write_read_finish. Check
resource->handle() == null and return ALREADY_CLOSED before calling
resource->bus() or finish_controller_operation, preventing null-bus
dereferences.

Comment on lines +97 to +109
done := List 4: monitor.Latch
errors := List 4: null
4.repeat: | task-index/int |
task::
errors[task-index] = catch --unwind:
25.repeat: | iteration/int |
index := task-index * 53 + iteration
expected := initial[index]
expect-equals #[expected] (device.write-read #[index] 1)
done[task-index].set true
done.do: it.get
errors.do: | error/any? |
if error: throw error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

catch --unwind prevents the error collection and can hang the test.

--unwind rethrows the exception inside the task. The assignment to errors[task-index] and the following done[task-index].set true never run. done.do: it.get then waits forever, and the failure appears as a test hang instead of the intended error report.

Remove --unwind so the error is captured and the latch is always set.

🐛 Proposed fix
   4.repeat: | task-index/int |
     task::
-      errors[task-index] = catch --unwind:
+      errors[task-index] = catch:
         25.repeat: | iteration/int |
           index := task-index * 53 + iteration
           expected := initial[index]
           expect-equals #[expected] (device.write-read #[index] 1)
       done[task-index].set true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
done := List 4: monitor.Latch
errors := List 4: null
4.repeat: | task-index/int |
task::
errors[task-index] = catch --unwind:
25.repeat: | iteration/int |
index := task-index * 53 + iteration
expected := initial[index]
expect-equals #[expected] (device.write-read #[index] 1)
done[task-index].set true
done.do: it.get
errors.do: | error/any? |
if error: throw error
done := List 4: monitor.Latch
errors := List 4: null
4.repeat: | task-index/int |
task::
errors[task-index] = catch:
25.repeat: | iteration/int |
index := task-index * 53 + iteration
expected := initial[index]
expect-equals #[expected] (device.write-read #[index] 1)
done[task-index].set true
done.do: it.get
errors.do: | error/any? |
if error: throw error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/hw/esp32/i2c-async-controller-shared.toit` around lines 97 - 109,
Remove the --unwind option from the catch expression inside the task block so
exceptions are stored in errors[task-index], allowing done[task-index].set true
to execute and the later errors.do propagation to report failures without
hanging.

errors.do: | error/any? |
if error: throw error

if system.architecture != system.ARCHITECTURE-ESP32:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Lines 199-210 are unreachable, and the two architecture guards are redundant.

test-board1 returns at line 53 for ESP32. All code after line 54 runs only on non-ESP32 targets. Therefore:

  • The guards at line 111 and line 161 are always true.
  • The block at line 161 ends with return at line 197. The CLOSE handshake, the bus reuse check, and port.close at lines 199-210 never run.

The missing CLOSE handshake also makes the CLOSE branch in test-board2 unreachable for non-ESP32 boards, so board2 never closes its targets or its UART port through the normal path. The bus reuse check at lines 205-209 also duplicates the one at lines 182-187.

Remove the redundant guards, drop the return at line 197, and place the destructive timeout case after the CLOSE handshake, or delete the duplicated block at lines 199-210 and document that board2 relies on the rig reset.

Also applies to: 161-210

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/hw/esp32/i2c-async-controller-shared.toit` at line 111, Remove the
redundant architecture guards in test-board1, eliminate the return that exits
before cleanup, and ensure the normal CLOSE handshake, bus reuse check, and
port.close execute before any destructive timeout path. Update test-board2 so
its CLOSE branch remains reachable and closes targets and the UART normally;
remove the duplicated bus reuse logic while preserving the intended cleanup
behavior.

@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch 3 times, most recently from 06a1eb7 to 2911d39 Compare August 11, 2026 21:45
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from 2911d39 to fe4dcd0 Compare August 11, 2026 21:54
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch 2 times, most recently from b576370 to 70dee26 Compare August 12, 2026 22:17
@floitsch
floitsch force-pushed the floitsch/i2c.30-async-controller branch from 70dee26 to f7763a2 Compare August 12, 2026 22:31
@floitsch
floitsch marked this pull request as ready for review August 12, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant