summaryrefslogtreecommitdiff
path: root/test/tx/regression/threadx_thread_exit_callback_transition_test.c
AgeCommit message (Collapse)Author
2026-09-10Refused thread delete and reset while an exit transition is in progress (#724)Frédéric Desbiens
_tx_thread_shell_entry and _tx_thread_terminate both publish a thread's terminal state -- TX_COMPLETED or TX_TERMINATED -- and then call that thread's exit notification callback, before the thread has been detached from the ready list and before either service has finished with the pointer it holds to the control block. That terminal state is exactly the state _tx_thread_delete and _tx_thread_reset accept as authorization to invalidate or rebuild the control block, and neither service tested whether the transition producing it had finished. A callback could therefore delete the thread it was called for -- and then lawfully recreate it over the same memory, since delete exists to permit that -- while the scheduler was still linked to the old incarnation. tx_thread_create zeroes the whole control block and can auto-start the new one, so the old priority list is left heading at a block whose own priority field names a different list, with the old priority's map bit set behind nothing. Alternatively a callback could reset a terminated thread, which moves it out of the terminal state, and then resume it: the interrupted- suspension logic in _tx_thread_system_resume refuses to void a suspension only while the state is still terminal, so with the reset allowed first the resume clears the suspending flag and restores TX_READY, the outer service then finds the flag clear and skips the removal, and tx_thread_terminate returns TX_SUCCESS for a thread that is runnable again. Interrupt masking does not close the window, because the kernel restores the prior posture before invoking the callback deliberately. On SMP TX_RESTORE also releases the global protection, so the target can be executing on another core while its callback runs -- and a reset there memsets the stack a live core is running on. On the Linux, Win32 and Win64 host simulation ports the consequence is more immediate than corruption: TX_THREAD_DELETE_PORT_COMPLETION cancels and joins the host thread backing the deleted thread, so a callback-side delete of the completing thread destroys the host thread the callback is running on. The fix marks the transition and has the two services refuse a marked target, returning the errors they already document, TX_DELETE_ERROR and TX_NOT_DONE. The refusal is transient and the same call succeeds once the transition has completed, so no documented lifecycle is lost; and it is in the core services rather than the _txe_ wrappers, so disabling error checking cannot disable it. Refusing the reset is also what closes the resume path, without touching _tx_thread_system_resume: its existing terminal-state test is sufficient once nothing can turn the terminal state into TX_SUSPENDED from inside the window. tx_thread_suspending is the marker, rather than a new control-block field. It already means "a suspension is in progress" and is already true across the callback in the two interruptable paths, so no field is added, the public structure is unchanged, and sizeof(TX_THREAD) is unchanged -- which matters, because the Module Manager's object handling depends on the sizes of the control blocks. Widening its lifetime was checked against every reader rather than assumed. There are four: two in _tx_thread_system_suspend and two in _tx_thread_system_resume. In every window this change widens, the state is TX_COMPLETED or TX_TERMINATED, and both resume readers already refuse to void a suspension for exactly those two states, so their behaviour is unchanged; and no suspension routine is called on the target in those windows, so the suspend readers never see them. No suspension-initiating service can set the marker again inside a window either: every one of them acts on a thread that is ready or suspended. Three sites needed changing beyond the two refusals, and the shape of each was decided by where the marker can safely be cleared: - The non-ready branch of _tx_thread_terminate cleared the marker before the terminated extension and the callback, which is what left them free to act on a control block the service still had mutex-release processing to do against. The clear moves to the common tail, after the last dereference of the target, and becomes the single clear site for the whole service. In the interruptable ready branch the flag is already false there, because _tx_thread_system_suspend cleared it when it detached the thread, so the tail store is a second store of a value the flag already holds -- cheaper than testing for it, and it keeps one clear site. - Under TX_NOT_INTERRUPTABLE neither path set the marker at all, because that configuration does not use the interruptable suspension path that sets it. Both now set it before the callback. Interrupts being disabled there does not help: the callback is reached by a direct call. - In the TX_NOT_INTERRUPTABLE completion path the marker is cleared before _tx_thread_system_ni_suspend rather than after it. That call returns to the scheduler for a thread that is the current thread, which a completing thread is, and does not come back; clearing afterwards would leave a normally completed thread marked for ever and therefore permanently undeletable. Nothing is lost by clearing early there, because everything from that point to the detachment runs with interrupts disabled and calls no application code. The change is the same change twice. All four files are byte-for-byte identical between common and common_smp at this commit and stay so after it, so common_smp was written by copying rather than by repeating the edits. tx_thread_system_suspend.c and tx_thread_system_resume.c, which do differ between the kernels, are deliberately untouched. Tests. The in-tree regression test goes to both trees and is byte-for-byte identical between them. It drives seven scenarios: terminating a ready non-current target with two peers ready at the same priority, with the callback attempting the delete and recreating the block if it succeeded; the same with the callback attempting the reset and then the resume; terminating a target suspended on a semaphore while owning a mutex, which is the non-ready branch; natural completion alone at its priority, including the safe post-completion reset, terminate, delete and recreate at another priority; self termination; a benign callback, whose notification count and ordering are unchanged; and the state and boundary cases, where the new refusal must not fire. It measures rather than describes. The callback records the published state, the marker, and the status of every lifecycle service it can reach, calling the core service as well as the wrapper wherever a refusal is expected. A snapshot taken under interrupt lockout -- which is the global SMP protection on an SMP port -- checks that every ready list agrees with the control blocks it heads, that the priority map agrees with the lists, and that each execute pointer is a member of the list its own priority field names. Every walk is bounded, so a corrupted ring costs an assertion and not a hang, and no test in the suite can hang. Expectations are counted inside a scenario and gated between scenarios, so a failing kernel reports how much it failed by without being driven further into its own corruption. The consequences are demonstrated from the terminator's context rather than the completing thread's, which is what makes the pre-fix behaviour an assertion instead of a wedged simulator. Compiled against the unfixed sources the test fails 9 of the 24 expectations it reaches in the uniprocessor tree and 10 of 24 in the SMP tree, and the failures are the finding: the callback-side delete succeeds, the recreate succeeds, the consistency snapshot disagrees, the target is neither terminal nor detached when the service returns, and a peer has left the ready ring the recreated block hijacked. The SMP tree gets a second test for the case that needs concurrency. The victim is excluded to core 1 and spins there without relinquishing while the controller, excluded to core 0, terminates it, so the callback runs on one core while the target executes on another. The callback-side reset and delete must both be refused, and a sentinel written into the unused low end of the victim's stack must survive -- a reset would have memset the whole stack before rebuilding the frame. Every wait is bounded, and if the remote precondition cannot be established the test says so and drops only the assertions that depend on it rather than reporting a pass it did not earn; measured over twenty consecutive runs it established the precondition every time. TX_NOT_INTERRUPTABLE and TX_DISABLE_ERROR_CHECKING are not among the five build configurations either tree compiles, and each tree builds the whole library once per configuration, so neither can be reached from inside the suites. The lines this change adds under TX_NOT_INTERRUPTABLE are therefore in no configuration the trees build, and they are where the permanent- undeletability failure mode lives, so they get their own harness rather than a compile check: the four sources plus the two error wrappers are compiled directly into a test executable, once per combination, with recorders standing behind the scheduler services they call. That is what makes the marker's value at the moment of detachment directly observable. It runs 66 expectations under TX_NOT_INTERRUPTABLE, 66 under that with error checking disabled, 45 under that with notification disabled, and 63 under error checking disabled alone; against the unfixed sources those fail 25, 22, 6 and 20 respectively. The harness lives in the uniprocessor tree only, because the four sources are identical between the kernels and the shim replaces the very primitive the SMP port differs in, so a second copy would compile the same text under the same macros. It is deliberately left out of the coverage instrumentation, since the same source under different feature macros has a different line set and merging those would confuse the union rather than add to it. Results. Both suites pass in all five configurations with GCC 14: 103 of 103 in the uniprocessor tree, up from 98, and 116 of 116 in the SMP tree, up from 114. Merged line coverage is 100% in the uniprocessor tree and 5172 of 5183 in the SMP tree, whose eleven uncovered lines are the same eleven that were uncovered before this change and are in tx_byte_pool_search and tx_thread_smp_utilities; all four changed files are at 100% line coverage in both trees, and SMP branch coverage rises from 2819 of 3548 to 2831 of 3556. Cross-compiled with arm-none-eabi-gcc at -Wall -Wextra for Cortex-M4 against common and for Cortex-A7 SMP against common_smp, all four files produce no diagnostics at all and an identical warning set to before the change, under -std=gnu99 and -std=c99 alike -- unlike the module ports, -std=c99 does not fail on these base ports, and even -Wconversion is clean. No MISRA deviation is required: explicit comparisons to TX_TRUE, existing ThreadX types, single-entry and single-exit control flow, no goto, and two added constant-time tests that change no real-time complexity. Assisted-by: Claude Code (Opus 5) <[email protected]>