Mic_Class: serialize record() and lifecycle changes without blocking end() - #351
Merged
Merged
Conversation
…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.
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.
Overview
Serializes
Mic_Class::record()against concurrent callers and against lifecycle changes (begin()/end()), and makesbegin()'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
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.record()racingend()can publish a request into a port that was just torn down, while still returningtrue.record()overloads read_cfg.sample_rateoutside any lock, and the capture task re-reads it after startup — a queuedrecord()with a new rate could retune the clock under a request still being captured.begin()did not verify that the capture task actually enabled the I2S channel, so an_i2s_start()failure leftrecord()returningtruefor requests that could never complete.end()and the capture task relied onvolatileand on notifying a self-deleting task (racy against the TCB teardown; additionally, ESP-IDF 4.0–4.2 report an indefinite notification wait aseSuspended, so a scheduler-state check alone can act before the task's cleanup ran).Design
_rec_lock;record(),end()and the publicbegin()all take_rec_lockbefore_begin_lock.end()never waits behind a full queue or a stalled capture._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._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.vTaskSuspend;_end_locked()deletes it only after the ack and the park._task_running/_task_handleare atomics.begin()waits (bounded, 1 s) for the task to actually enable the channel on every board and fails cleanly otherwise; the rawrx_updatelatch 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
record()with varying lengths/rates plus a third task cyclingend()/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).advanced/Mic_FFTexample verified on the device.