myxce/.wolf/bugs/bug-1810.md
Rongsong Shen d26ff8870d
feat(buglog): one Markdown record per bug, checked against a record standard
The buglog was one 27K-line JSON file whose fields covered only symptom,
root cause and fix, as free text: 2% of entries named the code version a
bug was found on, 12% the build profile, 36% a way to reproduce, and 58% of
the file was OpenWolf hook auto-detections ("Missing guard clause",
"Function not marked async" on .md files).

- .claude/skills/buglog/bug-record.md: the record standard. Eight
  sections (Summary as the title, Description, Environment, Reproduction;
  Root cause, Fix, Verification, References once fixed), each with the
  content it must contain -- e.g. Environment needs the OS, the libc the
  failing binary loads (from its ELF interpreter: glibc 2.42 in the nix
  shell vs 2.40 on the host), toolchain, build profile and commit.
- .wolf/bugs/<id>.md: 500 records migrated from buglog.json; every field
  verified to round-trip. They carry legacy: true and an explicit "not
  recorded" where the old log had no such field; component derived from
  file paths, severity from keywords (else unknown), fix_commits from
  hashes in status_note/fix that git resolves (91 of 463 fixed).
- .wolf/buglog-archive.json: the 710 hook auto-detections, verbatim.
- .wolf/buglog.json is now an index generated from the records, keeping
  the fields OpenWolf's hooks and `openwolf bug search` read.
- scripts/buglog.py rewritten: new (template + Environment collected from
  the machine and --binary), check, status (refuses a status the record
  doesn't support; fixed needs Root cause, Fix, Verification and
  --commit hashes git resolves), note, recur, set, show, list, triage,
  env, index. 29 unittest cases in scripts/tests/test_buglog.py.
- scripts/git-hooks/pre-commit runs `check --staged`: incomplete staged
  records, a legacy record hand-changed to fixed without the fix-time
  sections, a new record claiming legacy: true, or a stale index refuse
  the commit.
- scripts/gitea-sync.py reads the records: issue body = the record's
  sections plus linked fix commits (origin remote, GITEA_COMMIT_REPO to
  override), and a body hash so an issue's text is refreshed when the
  record changes.
- bug-1866 rewritten to the full standard as the worked example.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 03:40:11 +00:00

38 lines
7 KiB
Markdown

---
id: "bug-1810"
status: "fixed"
status_updated: "2026-09-24"
status_note: ""
component: "lib"
severity: "hang"
timestamp: "2026-09-15T03:55:00Z"
last_seen: "2026-09-19T09:00:00Z"
occurrences: 1
files: ["src/lib/mylock.c (cbuf_put/cbuf_get/cbuf_timedget)", "src/lib/vtaskmgr.c (vcpu_idle", "vcpu_entry)"]
tags: ["libxce", "mylock", "vtaskmgr", "cbuf", "pthread_cancel", "pthread_cond_wait", "cleanup-handler", "orphaned-mutex", "deadlock", "hang", "xce_test", "harness", "ctrl-d", "eof", "quit", "teardown", "race-condition", "tdd-red-green", "live-gdb-repro"]
related: []
legacy: true
---
# bug-1810: User-reported live hang: xce_test (harness REPL) never exits on Ctrl-D (EOF) after entering a live…
## Description
User-reported live hang: xce_test (harness REPL) never exits on Ctrl-D (EOF) after entering a live connection context (client/worker/scheduler). Live gdb attach on the user's machine showed the main thread parked forever in leave_ctx() -> xcecli_ctx_fini() -> xce_ctx_fini() -> vtaskmgr_fini() -> vcpu_fini()'s pthread_join(vcpu->vcpu, ...); a sibling vcpu thread was simultaneously deadlocked in cbuf_put() at mylock.c:101's pthread_mutex_lock(&(b->mutex)) on vtaskmgr->wbuf.mutex, whose __owner TID had already exited.
## Environment
_Not recorded (migrated from buglog.json)._
## Reproduction
_Not recorded (migrated from buglog.json)._
## Root cause
Two compounding defects in src/lib's shared task-pool primitives (used by every daemon + xce_test's own client/worker/scheduler REPL contexts, each of which owns a vtaskmgr_t for async I/O dispatch). (1) mylock.c's cbuf_put()/cbuf_get()/cbuf_timedget() lock b->mutex, then block in pthread_cond_wait()/pthread_cond_timedwait() with NO pthread_cleanup_push() handler installed. POSIX mandates pthread_cond_wait() reacquire its mutex before honoring a pending cancellation and running cleanup handlers -- with no handler registered, a thread cancelled while parked there terminates HOLDING the mutex, orphaning it locked forever; every other thread that later calls pthread_mutex_lock() on that same cbuf deadlocks uninterruptibly (plain mutex lock is not a cancellation point in glibc's NPTL). (2) vtaskmgr.c's teardown protocol races directly into that hazard from up to three independent angles, all torn down via pthread_cancel()+pthread_join(): (a) vtaskmgr_fini() cancels the dispatcher thread FIRST -- if it lands while the dispatcher is blocked in pick_idle_vcpu()'s cbuf_get() on the shared vtaskmgr->wbuf (empty, e.g. because real I/O work had every vcpu momentarily busy), that orphans wbuf's mutex immediately, before any vcpu teardown even starts; (b) vcpu_fini() then wakes each idle vcpu via bind_vtask(NULL, vcpu) (a 'poison pill' through the vcpu's own 1-capacity wbuf) and immediately pthread_cancel()s it, but vcpu_entry()'s loop could not distinguish that pill from 'no task yet' -- it looped straight back into vcpu_idle(), which unconditionally re-announced the vcpu into the shared (by then permanently undrained, since the dispatcher is already dead) vtaskmgr->wbuf, racing the pending cancellation against that second cbuf_put(); (c) the same poison pill arriving while a vcpu was still mid-execution of a real task queued it silently for the NEXT vcpu_idle() call, which still re-announced into the shared queue first before ever consuming it. Any of the three could orphan vtaskmgr->wbuf's mutex; every vcpu processed afterward that still touched the shared queue then deadlocked forever on pthread_mutex_lock(), and vtaskmgr_fini()'s pthread_join() for it never returned -- hanging the whole process (xce_test on Ctrl-D quit; identically reachable from any daemon that tears down a vtaskmgr_t under real concurrent I/O, e.g. controller/scheduler/worker shutdown).
## Fix
FIXED (2026-09-15), TDD RED->GREEN. Two-part fix, both required (confirmed empirically -- the vtaskmgr.c-only fix alone left the dispatcher-orphan path (2a above) unaddressed and still reproduced the hang under stress). (1) mylock.c: wrapped the pthread_cond_wait()/pthread_cond_timedwait() loops in cbuf_put()/cbuf_get()/cbuf_timedget() with pthread_cleanup_push(pthread_mutex_unlock, &b->mutex) / pthread_cleanup_pop(0) -- a cancelled waiter now always releases the mutex on unwind, so no cbuf mutex can ever be left orphaned-locked by a cancellation, regardless of which caller (dispatcher or vcpu) hits it. cbuf_timedget()'s early ETIMEDOUT return was restructured to break+flag instead of returning from inside the pushed region, since push/pop must be a matched pair at the same lexical nesting level. (2) vtaskmgr.c, defense-in-depth / closes the underlying logic bug independently of (1): vcpu_idle() now does a non-blocking cbuf_tryget() on its own wbuf BEFORE announcing itself into the shared vtaskmgr->wbuf, so an already-queued poison pill (queued while the vcpu was mid-task) is consumed directly instead of triggering a needless re-announce; vcpu_entry()'s loop now checks vcpu->task after vcpu_idle() returns and breaks (exits the thread) on NULL instead of looping back into idle -- a NULL task is unambiguously vcpu_fini()'s shutdown signal, since the dispatcher (vdispatcher_entry) never calls bind_vtask() with a NULL task. New regression test src/lib/testsuite/test_vtaskmgr_teardown.c (wired into src/lib/meson.build + testsuite/test.c): floods a burst of real VTASK_LIVENESS tasks across a 16-vcpu vtaskmgr then calls vtaskmgr_fini() on a helper thread with a pthread_timedjoin_np() 2s bounded-timeout join, self-pinning the test to a single CPU (sched_setaffinity, inherited by every spawned vtaskmgr thread at clone()) since the race needs preemptive time-slicing to reproduce reliably -- confirmed 0/hundreds of hangs on stock multi-core scheduling (true parallelism lets a vcpu almost always win the race to its own pthread_testcancel() before cancellation is even issued) vs. reliable reproduction (hung on iteration 0) once pinned to one core. Verified via: (a) this new test, RED before the fix (Expected 0 Was 110/ETIMEDOUT) and GREEN after; (b) a standalone single-core-pinned stress harness outside the Unity suite, 2000/2000 iterations clean post-fix (vs. hanging on iteration 0 with no fix and again on iteration 3 with only the vtaskmgr.c half of the fix, confirming both parts were needed); (c) full libxce test suite (314/314) and full harness suite (132/132, including the pty-based clitest tests that exercise real client-context connect+quit) both green; (d) a live pty-driven repro of the ORIGINAL user report -- launching the real xce_test binary, entering the client context, sending a real Ctrl-D (EOT) byte -- now exits in <0.1s (previously would hang indefinitely); (e) full project meson test matrix (98/98 suites) green on a clean run -- two earlier full-matrix runs showed unrelated, non-reproducing timeouts (one in harness's own quit-wait, one in controller_unit_test) that did not reproduce on isolated reruns, consistent with this sandbox's pre-existing parallel-test-suite CPU-contention flakiness (see bug-1429/bug-1580), not a regression from this fix.
## Verification
_Not recorded (migrated from buglog.json)._
## References
_Not recorded (migrated from buglog.json)._