Skip to content

Mic_Class: serialize record() and lifecycle changes without blocking end() - #351

Merged
lovyan03 merged 1 commit into
m5stack:developfrom
ainyan03:mic_record_serialization
Sep 2, 2026
Merged

Mic_Class: serialize record() and lifecycle changes without blocking end()#351
lovyan03 merged 1 commit into
m5stack:developfrom
ainyan03:mic_record_serialization

Conversation

@ainyan03

@ainyan03 ainyan03 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Overview

Serializes Mic_Class::record() against concurrent callers and against lifecycle changes (begin()/end()), and makes begin()'s result honest on every board. Same family as the Speaker slot state-machine fix (#295), surfaced while auditing #348.

Problems in the current code

  • Two concurrent record() callers can claim the same slot: their plain metadata writes race, and the capture task can pair one caller's buffer with the other's length.
  • A record() racing end() can publish a request into a port that was just torn down, while still returning true.
  • The default-rate record() overloads read _cfg.sample_rate outside any lock, and the capture task re-reads it after startup — a queued record() with a new rate could retune the clock under a request still being captured.
  • On boards without a post-start callback, begin() did not verify that the capture task actually enabled the I2S channel, so an _i2s_start() failure left record() returning true for requests that could never complete.
  • The stop handshake between end() and the capture task relied on volatile and on notifying a self-deleting task (racy against the TCB teardown; additionally, ESP-IDF 4.0–4.2 report an indefinite notification wait as eSuspended, so a scheduler-state check alone can act before the task's cleanup ran).

Design

  • All request publishing happens under a dedicated _rec_lock; record(), end() and the public begin() all take _rec_lock before _begin_lock.
  • The locks cover only bounded work (one begin + one claim attempt). When both slots are busy, or a rate change still has recordings draining, the caller releases the locks, waits outside them, and retries — end() never waits behind a full queue or a stalled capture.
  • Requests carry a wrap-safe sequence number stamped under _rec_lock; the capture task consumes the pending slot with the older stamp. Slot identity cannot express publish order once a freed slot is refilled.
  • The requested sample rate is validated and committed under _begin_lock (zero and overflow-prone values are rejected without poisoning the stored config), and the capture task uses the rate snapshot fixed before it was created.
  • The capture task acknowledges its I2S cleanup through a dedicated atomic and parks itself with vTaskSuspend; _end_locked() deletes it only after the ack and the park. _task_running/_task_handle are atomics.
  • begin() waits (bounded, 1 s) for the task to actually enable the channel on every board and fails cleanly otherwise; the raw rx_update latch wait is bounded and its timeout fails the begin.

Documented contracts (in the headers): the enable/post-start callbacks must not call begin()/end()/record(); config()/setSampleRate() (getters included) are stopped-port operations; the locks are per-instance, so two instances driving the same I2S port stay unsupported; the admission lock is an unfair CAS with no arrival order.

Verification

  • Hardware stress on StopWatch (ESP32-S3): two tasks issuing back-to-back record() with varying lengths/rates plus a third task cycling end()/begin(), 10 minutes per iteration of this patch — no hang, no crash, no heap drift (~5,600 record iterations + ~500 end/begin cycles per run).
  • Builds clean on the new I2S driver (ESP32-S3/IDF5), the legacy I2S driver (ESP32/IDF4-era core), and the native SDL port.
  • The stock advanced/Mic_FFT example verified on the device.

…end()

Two record() callers could claim the same slot (their plain metadata
writes then race, and the capture task can pair one callers buffer with
the others length), and a record() racing a lifecycle change could
publish a request into a port that was just torn down while still
returning true. The default-rate record() overloads also read
_cfg.sample_rate outside any lock, and the capture task read it again
after startup, so a queued record() with a new rate could retune the
clock under a request already being captured.

All request publishing now happens under a dedicated _rec_lock, and
every lifecycle path joins the same order: record(), end() and the
public begin() each take _rec_lock before _begin_lock. The requested
sample rate is validated and committed inside _begin_raw() under
_begin_lock: a rate of 0, or one whose product with the effective
over_sampling would wrap uint32_t, is rejected without poisoning the
stored config. The default-rate overloads pass 0 (keep the current
rate) instead of reading the config outside the lock, and an explicit
sample_rate of 0 is rejected up front. The capture task uses
_rec_sample_rate - fixed before the task is created - instead of
re-reading _cfg.sample_rate, and _calc_rec_rate() clamps over_sampling
to the same 1..8 range the task itself uses.

The locks cover one begin plus one claim attempt. When both slots are
busy, or a rate change still has recordings draining, the caller
releases the locks, waits outside them, and retries, so end() never
waits behind a full queue or a stalled capture. The lock itself is an
unfair CAS: contending callers have no arrival order, which is
documented at the retry site.

Requests carry a wrap-safe sequence number stamped under _rec_lock,
and the capture task consumes the pending slot with the older stamp.
Slot identity cannot express publish order - a freed slot can be
refilled while the other still holds an earlier request, and cursor
parity schemes desynchronize across the tasks idle transitions. The
tasks two slot reads are not one snapshot, so an empty slot 0 is
re-read once slot 1 is known pending before the sequence compare. The
capture task itself takes no locks; slots still hand over through the
length release/acquire protocol.

begin() now reports honest results on every board: it waits for the
capture task to actually enable the I2S channel (previously only
boards with a post-start callback did, so an _i2s_start() failure left
record() returning true for requests that could never complete), and
it fails cleanly when the task semaphore cannot be allocated. The
teardown handshake is restructured: the capture task acknowledges its
I2S cleanup through a dedicated atomic, parks itself with vTaskSuspend
instead of self-deleting, and _end_locked() deletes it only after the
ack and the park - notifying a self-deleting task raced against its
TCB teardown, and the ack (rather than eTaskGetState alone) matters
because ESP-IDF 4.0-4.2 report an indefinite notification wait as
eSuspended, which would otherwise pass the check before cleanup ran. _task_running and _task_handle are atomics,
and the raw rx_update wait is bounded, with its timeout failing the
begin instead of starting the channel on an unlatched clock. A
persistently failing I2S read backs off one tick per retry so a
high-priority capture task cannot starve the stop request. The
effective rate is capped below 2^31 so the divider search never wraps
internally, and the slot-order compare uses an unsigned half-range
test with no signed-conversion dependence.

The callbacks run while these non-recursive locks are held; the
contract now spells out that they must not call record() either.
config() and setSampleRate() remain unsynchronized and are documented
as stopped-port operations, getters included. The locks are
per-instance: two Mic_Class instances driving the same I2S port stay
unsupported (the driver state is port-global).

Same family as the Speaker slot state machine fix (m5stack#295); the Mic side
keeps a writer lock plus sequence stamps instead of per-slot CAS
because its slots have a single consumer.
@lovyan03
lovyan03 merged commit b7d58ff into m5stack:develop Sep 2, 2026
27 checks passed
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