diff --git a/goals/G-036-tcl9-udp-console-worker-wedge.md b/goals/G-036-tcl9-udp-console-worker-wedge.md index c7a5e7c0..66c15d1a 100644 --- a/goals/G-036-tcl9-udp-console-worker-wedge.md +++ b/goals/G-036-tcl9-udp-console-worker-wedge.md @@ -215,6 +215,61 @@ live shell. Next candidates: Round-2 artifacts: %TEMP%/punk_wedge2.dmp + _stacks/_disasm/_h2 logs. +## Findings round 3: ROOT CAUSE - tcludp 1.0.12 UDP_ExitProc closes process-global events at thread exit (2026-07-08) + +Live-attach WinDbgX with a conditional breakpoint on KERNELBASE!CloseHandle for the +sockListLock handle value (read from the tcludp statics at attach; trap script listing in +scratchpad/notes) caught the closer red-handed: the CloseHandle is invoked from Tcl's +thread-exit-handler walker in a dying Thread-extension worker, with no udp frame visible +(tail-call). The fossil diff `tcludp-1_0_12 -> current` names it exactly - **tcludp 1.0.12's +Windows per-thread exit handler**: + + /* UDP_ExitProc - called at thread exit */ + void UDP_ExitProc(ClientData clientData) { + Tcl_DeleteEventSource(UDP_SetupProc, UDP_CheckProc, NULL); + CloseHandle(waitForSock); <- process-global + CloseHandle(sockListLock); <- process-global + } + +So in the bundled tcludp 1.0.12, the FIRST udp-loaded thread to exit closes the process-wide +lock/wake events. Threads already waiting keep waiting on the orphaned object forever (no +handle left to signal it); later waiters wait on whatever kernel object RECYCLED the handle +value (the round-2 dump's IoCompletion) and block forever; if the value happens to be +unrecycled, WaitForSingleObject fails fast and execution limps on silently - which is exactly +why the standalone die-worker probes stayed green (little handle churn after the close) while +punkshell's run-2 startup (heavy handle churn immediately after the transition-time worker +death) wedged 4/4. + +Full causal account of the original symptom: +1. Piped-stdin run 1 creates a syslog log worker (udp loaded, socket opened). +2. At the run-1 -> run-2 transition, punkshell's between-runs teardown terminates that worker + (pre-fix via unsubscribe/shutdown path; post-fix shutdown_free_threads still terminates + free workers between runs) -> UDP_ExitProc closes the globals. +3. Run-2's syslog workers load udp (event-source procs registered), their first event-loop + passes block forever in WaitForSingleObject(sockListLock) on a recycled handle. +4. exit/quit's synchronous teardown send to the wedged worker froze the shell (mitigated in + shellthread 1.6.3 / shellfilter 0.2.4). + +**Upstream status: already fixed.** The 1.0.12 -> 1.0.13/trunk rewrite (tcludp-1-0-13 tagged +2026-06-27; "Code Not Thread-Safe" ticket closed 2026-07-05) replaces UDP_ExitProc with +UdpThreadExitProc (per-thread: deletes the event source only) + ExitSockets (process exit +handler: closes the events). No upstream report needed for the killer bug. + +**Remedy for punkshell: upgrade the bundled tcludp to 1.0.13+** (kits bundle tcl9udp1012.dll += 1.0.12). Re-verify with the in-context batch harness after the upgrade (baseline: punk902z +run-2 syslog workers wedged 4/4 -> expect 4/4 alive). + +Loose ends / optional follow-ups: +- Check which tcludp the punksys (8.6) kit bundles and why 8.6 appeared immune (different udp + version, different exit-handler ordering, or fast-fail luck). +- Residual weaknesses still present in tcludp trunk, candidates for polite upstream tickets: + auto-reset-event-used-as-lock (lost-SetEvent risk, no ownership); packetNum not decremented + on the recvfrom error path (ICMP port-unreachable leaves packetNum>0 -> permanent + Tcl_SetMaxBlockTime({0,0}) poll mode for the owning thread and the socket excluded from + select forever); get_tag_config-style INFINITE waits with no watchdog. +- The latent hazard class in punkshell remains mitigated (teardown never blocks on a wedged + worker) regardless of the upgrade. + ## Alternatives considered - Mitigate-only (make the sync send robust, default syslog off) - rejected as the *whole*