|
| 1 | +## Recorder-token ownership at blocking native parks |
| 2 | + |
| 3 | +The b1.2.0 heavy threaded-flush gate exposed a recorder-token liveness cycle. |
| 4 | +A main TG entered `threading.thread:join()` while it still owned the global JIT |
| 5 | +recorder token. A child running `jit.flush()` asynchronously changed the |
| 6 | +recorder state from active `LJ_TRACE_RECORD` to aborted `LJ_TRACE_RECORD`, then |
| 7 | +waited to acquire that token. The owner was already parked waiting for the |
| 8 | +child, and only the owner could finish recorder cleanup and release the token: |
| 9 | + |
| 10 | +``` |
| 11 | +main: thread:join -> native futex park (owns jit_token) |
| 12 | +child: jit.flush -> lj_jit_token_acquire_wait (must finish before exit) |
| 13 | +``` |
| 14 | + |
| 15 | +This was not a slow stress case. The exact heavy case failed twice at its |
| 16 | +60-second join timeout; GDB showed `jit_token == main_tid`, `J->L == main`, |
| 17 | +`J->state == LJ_TRACE_RECORD` with `LJ_TRACE_ACTIVE` cleared, the main TG in |
| 18 | +`threading_futex_wait_l()`, and the sole live child spinning in |
| 19 | +`lj_jit_token_acquire_wait()`. |
| 20 | + |
| 21 | +### Rule |
| 22 | + |
| 23 | +A Lua/TG owner must not enter a potentially blocking native park while it owns |
| 24 | +unpublished recorder state. Immediately before the actual park it calls |
| 25 | +`lj_trace_abort_owner()`, which discards that owner's unpublished trace state |
| 26 | +and releases the token. Published traces are unaffected. |
| 27 | + |
| 28 | +The threading library applies the rule in its common futex-wait wrapper, which |
| 29 | +covers contended join, spawn/activation, mutex, and lifecycle waits. |
| 30 | + |
| 31 | +The rule is kept at concrete blocking sites instead of in |
| 32 | +`lj_native_enter_l()`. That helper publishes a native stack snapshot and is not |
| 33 | +itself a promise that its caller will block; making every future native-entry |
| 34 | +caller abort would unnecessarily constrain nonblocking C paths. |
| 35 | + |
| 36 | +### Channel audit and reentrant recorder boundary |
| 37 | + |
| 38 | +Channels have a separate futex substrate in `lj_chan.c`, so ordinary blocking |
| 39 | +and timed channel waits apply the same pre-park owner teardown after their |
| 40 | +optimistic spin fails. Try operations, zero-timeout operations, and handoffs |
| 41 | +completed during the spin remain untouched. |
| 42 | + |
| 43 | +A direct, unguarded application of `lj_trace_abort_owner()` was tested and |
| 44 | +rejected: a trace `start` event callback can block while `trace_state()` is |
| 45 | +still on the C stack. Tearing down `J->cur` reentrantly from that callback |
| 46 | +caused an immediate production-build segmentation fault. This applies equally |
| 47 | +to channel and threading-library parks. |
| 48 | + |
| 49 | +Each concrete park therefore compares the current TG id with |
| 50 | +`vmevent_owner_acq(g)`. The exact VM-event callback owner keeps the token and |
| 51 | +lets the outer recorder frame perform cleanup after it unwinds; all ordinary |
| 52 | +parks abort and release before sleeping. The guard is deliberately not folded |
| 53 | +into `lj_trace_abort_owner()`, whose detach/teardown callers have different |
| 54 | +lifetime obligations. |
| 55 | + |
| 56 | +The safe follow-up boundary is one of: |
| 57 | + |
| 58 | +- make contended JIT control logically invalidate/queue work without waiting |
| 59 | + for a token held by the callback; or |
| 60 | +- publish an explicit recorder-callback/reentrancy state, request abort, avoid |
| 61 | + the park, and finish cleanup only after `trace_state()` unwinds. |
| 62 | + |
| 63 | +The guarded callback still cannot synchronously wait for a peer whose |
| 64 | +`jit.flush()` must acquire its token. A bounded channel receive or timed join |
| 65 | +returns first, after which recorder unwind lets the peer finish. Making that |
| 66 | +cross-TG dependency synchronous requires the broader nonblocking JIT-control |
| 67 | +protocol above and remains b1.2.1 debt. |
| 68 | + |
| 69 | +### Validation |
| 70 | + |
| 71 | +- The pre-fix heavy case reproduced the join timeout twice. |
| 72 | +- The fixed production build completed the exact heavy threaded-flush case in |
| 73 | + five runs, including three consecutive repetitions. |
| 74 | +- The normal threaded-flush, safepoint-handshake, and recorder-token gates were |
| 75 | + rerun after the fix. |
| 76 | +- A deterministic join reducer keeps one single-round background worker and |
| 77 | + repeats the existing hot churn/flush loop. The unfixed exact source failed at |
| 78 | + round 40 in three consecutive runs; the fixed build completed all 96 joins. |
| 79 | +- A production Lua regression enters both a timed channel receive and a timed |
| 80 | + join from a trace `start` callback. Both waits return their bounded timeout, |
| 81 | + preserve the active recorder frame, and allow the peer flusher to complete |
| 82 | + after callback unwind; this catches the unguarded teardown crash. |
| 83 | +- The focused join and VM-event park regressions also pass a clean |
| 84 | + `-DLUA_USE_ASSERT` build. |
0 commit comments