Process debugging: ptrace, gdb and core dumps

Attach, inspect and capture a crash.

Advanced14 min · lesson 20 of 21

When a process hangs or crashes and its logs say nothing useful, you need to read the process itself: what each thread is waiting for, which lock it holds, which pointer was bad. This lesson does that with two small programs you compile yourself, one that deadlocks and one that crashes. You will attach a debugger to a running service within Ubuntu's ptrace restrictions, detach without harming it, capture and open a core dump through apport on Ubuntu and systemd-coredump on RHEL, and see where ltrace helps and where it misleads. The system-call view (strace) was the subject of the strace lesson; this lesson goes inside the process. The tools are not all in a default install: sudo apt install gcc gdb ltrace on Ubuntu Server, sudo dnf install gcc gdb ltrace on RHEL.

A process misbehaves and its logs do not explain it
What is the symptom?
Hung, 0% CPU
Threads and their locks
gdb: info threads, stacks from a core
Stuck in D state
Kernel side, not user space
/proc/PID/stack, wchan (processes lesson)
Crashed
The core dump
apport (Ubuntu), coredumpctl (RHEL)
Slow, busy CPU
Sample it instead
perf and flame graphs
A debugger stops the process it attaches to; the last two branches do not need one.

ptrace, and who may use it

Debuggers are built on ptrace(2), the system call that lets one process (the tracer) stop another (the tracee), read and write its memory and registers, and resume it. gdb, strace and ltrace all use it. Two consequences follow. The target is stopped while a tracer works on it, so attaching to a busy service pauses it for as long as you take. And ptrace can read anything in the target's memory, including keys and passwords, so the kernel restricts who may use it.

The first check is ownership: an unprivileged tracer must run as the same user as the target. The second is the Yama setting kernel.yama.ptrace_scope from the lesson "System calls and strace": Ubuntu's 1 allows an unprivileged tracer only its own descendants, RHEL's 0 any process of the same user, and CAP_SYS_PTRACE (a sudo) passes either; a hardened host may set 2 (root only) or 3 (no attaching until reboot). Here is what that means for a debugger.

A hung service: attach, read, detach

The first program starts a second thread. Each thread takes one mutex, sleeps a second so the timing never varies, then asks for the mutex the other one holds, a lock-order inversion, the classic deadlock. It is compiled with -g (debug information, so gdb can name variables and source lines) and -O0 (no optimisation, so the code follows the source). Create a directory for the lesson's programs first (mkdir -p ~/dt).

~/dt/dt-worker.c
/* dt-worker.c: two threads take the same two locks in opposite order */
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
static pthread_mutex_t stats_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t queue_lock = PTHREAD_MUTEX_INITIALIZER;
static void flush_queue(void)
{
pthread_mutex_lock(&queue_lock);
sleep(1);
pthread_mutex_lock(&stats_lock);
pthread_mutex_unlock(&stats_lock);
pthread_mutex_unlock(&queue_lock);
}
static void *flusher(void *arg)
{
(void)arg;
for (;;)
flush_queue();
return NULL;
}
static void collect_stats(void)
{
pthread_mutex_lock(&stats_lock);
sleep(1);
pthread_mutex_lock(&queue_lock);
pthread_mutex_unlock(&queue_lock);
pthread_mutex_unlock(&stats_lock);
}
int main(void)
{
pthread_t t;
pthread_create(&t, NULL, flusher, NULL);
pthread_setname_np(t, "flusher");
printf("dt-worker %d running\n", getpid());
fflush(stdout);
for (;;)
collect_stats();
}

Build it, then run the worker the way a real daemon runs: as a service under your own account, started by systemd. LimitCORE=infinity (no limit on the size of a core dump) will matter later.

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -p ~/dt
$ cd ~/dt gcc -g -O0 -pthread -o dt-worker dt-worker.c ls
dt-worker dt-worker.c
$ sudo systemd-run --unit=dt-worker --uid=$USER -p LimitCORE=infinity ~/dt/dt-worker
Running as unit: dt-worker.service; invocation ID: 2976351dd8e543829c20c2a3881d2ef8

A few seconds later the service is hung. Before reaching for a debugger, look from outside.

deploy@web01 · Ubuntu 26.04 LTS
$ pid=$(systemctl show -P MainPID dt-worker) ps -L -o pid,tid,stat,pcpu,wchan:18,comm -p $pid
PID TID STAT %CPU WCHAN COMMAND 257216 257216 Ssl 0.0 futex_do_wait dt-worker 257216 257225 Ssl 0.0 futex_do_wait flusher

ps -L lists threads. Both are asleep (S), use almost no CPU, and wait in the kernel's futex code (futex_do_wait): a futex is the kernel primitive under user-space locks, so both threads are blocked on locks. That rules out a busy loop and points at lock waits, but not at which lock. Now attach, as the account that owns the service.

deploy@web01 · Ubuntu 26.04 LTS
$ gdb -p $(systemctl show -P MainPID dt-worker) -batch -ex "thread apply all bt"
Could not attach to process. If your uid matches the uid of the target process, check the setting of /proc/sys/kernel/yama/ptrace_scope, or try again as the root user. For more details, see /etc/sysctl.d/10-ptrace.conf ptrace: Inappropriate ioctl for device.
$ sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 1
$ grep -v "^#" /usr/lib/sysctl.d/55-ptrace.conf
kernel.yama.ptrace_scope = 1

The same user was refused: the service was started by systemd, so your gdb is not its ancestor, and scope 1 forbids the attach. (gdb's hint names /etc/sysctl.d/10-ptrace.conf, an old location; Ubuntu 26.04 ships the setting in /usr/lib/sysctl.d/55-ptrace.conf.) Do not edit either file: a one-off sudo gets past the check for this attach only.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo gdb -p $(systemctl show -P MainPID dt-worker) -batch -ex "info threads"
[New LWP 257225] [Thread debugging using libthread_db enabled] Using host libthread_db library "/usr/lib/aarch64-linux-gnu/libthread_db.so.1". futex_wait (futex_word=0xb1e736dd0048 <queue_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 warning: 126 ../sysdeps/nptl/futex-internal.h: No such file or directory Id Target Id Frame * 1 Thread 0xe4906abadf60 (LWP 257216) "dt-worker" futex_wait (futex_word=0xb1e736dd0048 <queue_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 2 Thread 0xe4906a99f160 (LWP 257225) "flusher" futex_wait (futex_word=0xb1e736dd0018 <stats_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 [Inferior 1 (process 257216) detached]

info threads shows each thread's current frame. The main thread (LWP 257216, LWP being the kernel's name for a thread) waits in futex_wait on queue_lock; flusher waits on stats_lock. gdb resolved the futex addresses to the symbols of the two mutexes because the program has debug information. The last line matters as much: gdb detached, and the process carries on exactly as before.

Normally you would continue with thread apply all bt for full stacks. On this lab machine that fails, and the reason is worth knowing because it is not Ubuntu's doing.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo gdb -p $(systemctl show -P MainPID dt-worker) -batch -ex "thread apply all bt"
… Thread 2 (Thread 0xe4906a99f160 (LWP 257225) "flusher"): Python Exception <class 'gdb.error'>: Unable to fetch SVE/SSVE vector length: Invalid argument. #0 futex_wait (futex_word=0xb1e736dd0018 <stats_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 Thread 1 (Thread 0xe4906abadf60 (LWP 257216) "dt-worker"): Python Exception <class 'gdb.error'>: Unable to fetch SVE/SSVE vector length: Invalid argument. #0 futex_wait (futex_word=0xb1e736dd0048 <queue_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 [Inferior 1 (process 257216) detached]
$ ps -o pid,stat,etime,comm -p $(systemctl show -P MainPID dt-worker)
PID STAT ELAPSED COMMAND 257216 Ssl 00:03 dt-worker

This failure is an artefact of the lab's virtual machine: its virtual CPU (on an Apple M4 host) offers the SME matrix extension without SVE, and gdb 17.1 here (and 16.3 on the Rocky lab) cannot read a live thread's registers on such a CPU, so it stops after frame 0. On x86_64 servers and on ARM servers with SVE the command prints full stacks. When live unwinding fails, or when you must not hold a production process stopped while you think, take a core dump and read it at leisure. gcore PID writes one from a running process through ptrace, and the process stays frozen for as long as writing its whole memory takes, seconds to minutes for a large service; on this lab CPU it fails the same way. A service that is deadlocked will need a restart anyway, so here the core comes from killing it with SIGABRT, whose default action is to dump core.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl kill --signal=SIGABRT dt-worker
$ ls -l /var/lib/apport/coredump/
total 10880 -r-------- 1 deploy root 11141120 Sep 27 09:40 core._home_deploy_dt_dt-worker.1001.60492fd2-965b-4817-9fa7-8e0894522e50.257216.443343
$ gdb -batch -ex "thread apply all bt" -ex "print stats_lock.__data.__owner" \ -ex "print queue_lock.__data.__owner" ~/dt/dt-worker /var/lib/apport/coredump/core.*dt-worker*
… Core was generated by `/home/deploy/dt/dt-worker'. Program terminated with signal SIGABRT, Aborted. … Thread 2 (Thread 0xe4906a99f160 (LWP 257225)): #0 futex_wait (futex_word=0xb1e736dd0018 <stats_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 #1 __GI___lll_lock_wait (futex=futex@entry=0xb1e736dd0018 <stats_lock>, private=private@entry=0) at ./nptl/lowlevellock.c:49 #2 0x0000e4906aa2f7d8 [PAC] in lll_mutex_lock_optimized (mutex=0xb1e736dd0018 <stats_lock>) at ./nptl/pthread_mutex_lock.c:48 #3 ___pthread_mutex_lock (mutex=0xb1e736dd0018 <stats_lock>) at ./nptl/pthread_mutex_lock.c:87 #4 0x0000b1e736db0a90 [PAC] in flush_queue () at dt-worker.c:14 #5 0x0000b1e736db0ac4 in flusher (arg=0x0) at dt-worker.c:23 #6 0x0000e4906aa2c248 in start_thread (arg=0xe4906a99f160) at ./nptl/pthread_create.c:454 #7 0x0000e4906aa96b9c [PAC] in thread_start () at ../sysdeps/unix/sysv/linux/aarch64/clone3.S:75 Thread 1 (Thread 0xe4906abadf60 (LWP 257216)): #0 futex_wait (futex_word=0xb1e736dd0048 <queue_lock>, expected=2, private=0) at ../sysdeps/nptl/futex-internal.h:126 #1 __GI___lll_lock_wait (futex=futex@entry=0xb1e736dd0048 <queue_lock>, private=private@entry=0) at ./nptl/lowlevellock.c:49 #2 0x0000e4906aa2f7d8 [PAC] in lll_mutex_lock_optimized (mutex=0xb1e736dd0048 <queue_lock>) at ./nptl/pthread_mutex_lock.c:48 #3 ___pthread_mutex_lock (mutex=0xb1e736dd0048 <queue_lock>) at ./nptl/pthread_mutex_lock.c:87 #4 0x0000b1e736db0af0 [PAC] in collect_stats () at dt-worker.c:31 #5 0x0000b1e736db0b8c in main () at dt-worker.c:45 $1 = 257216 $2 = 257225
$ systemctl show -p Result -p ExecMainCode -p ExecMainStatus dt-worker
Result=core-dump ExecMainCode=3 ExecMainStatus=6

Read from the core, the stacks are complete. flusher blocked in flush_queue at line 14 asking for stats_lock; the main thread blocked in collect_stats at line 31 asking for queue_lock. The mutex's __owner field (glibc's record of the owning thread) closes the case: stats_lock is held by 257216, the main thread, and queue_lock by 257225, flusher. Each holds what the other wants. ([PAC] marks return addresses signed by ARM pointer authentication; x86_64 shows none.) systemd records the ending as Result=core-dump with signal 6.

Crashes: core dumps on Ubuntu and RHEL

A core dump is a file with the memory and registers of a process at the moment it died from a signal such as SIGSEGV. The kernel decides where it goes from kernel.core_pattern: a file name, or a | followed by a program that receives the core on its standard input. The second program has a bug: a header without a colon leaves a pointer NULL. Save it in ~/dt and build it the same way.

~/dt/dt-parse.c
/* dt-parse.c: prints the length of the value in a "Name: value" header */
#include <stdio.h>
#include <string.h>
struct header {
const char *name;
const char *value;
};
static size_t value_len(const struct header *h)
{
return strlen(h->value);
}
int main(int argc, char **argv)
{
struct header h = { argv[1], NULL };
char *colon = strchr(argv[1], ':');
/* bug: a line without ':' leaves value NULL */
if (colon)
h.value = colon + 1;
printf("%zu\n", value_len(&h));
return argc > 2;
}
deploy@web01 · Ubuntu 26.04 LTS
$ cd ~/dt gcc -g -O0 -o dt-parse dt-parse.c
$ ulimit -c ulimit -Hc cat /proc/sys/kernel/core_pattern
0 unlimited |/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -F%F -- %E
$ ~/dt/dt-parse 'Host: web01'
6
$ ~/dt/dt-parse 'Host web01'; echo "exit status $?"
-bash: line 1: 257654 Segmentation fault (core dumped) ~/dt/dt-parse 'Host web01' exit status 139
$ sudo tail -n 3 /var/log/apport.log
… INFO: apport (pid 257655) 2026-09-27 09:40:04,616: executable: /home/deploy/dt/dt-parse (command line "/home/deploy/dt/dt-parse Host\ web01") ERROR: apport (pid 257655) 2026-09-27 09:40:04,616: executable does not belong to a package, ignoring
$ ls /var/lib/apport/coredump/

On Ubuntu the pipe goes to apport, the crash reporter. The shell said (core dumped), yet nothing was kept. The kernel ignores RLIMIT_CORE for a pipe and passes it to the handler as %c, and apport follows its own rules: for a program that does not belong to a package it writes no crash report (the log says so), and it writes a core file only if the core limit allows one. Ubuntu's default soft limit is 0, both for logins and for services. Raise it for your shell and crash again:

deploy@web01 · Ubuntu 26.04 LTS
$ ulimit -c unlimited ~/dt/dt-parse 'Host web01'; echo "exit status $?" ls -l /var/lib/apport/coredump/
-bash: line 2: 257727 Segmentation fault (core dumped) ~/dt/dt-parse 'Host web01' exit status 139 total 284 -r-------- 1 deploy root 290816 Sep 27 09:40 core._home_deploy_dt_dt-parse.1001.60492fd2-965b-4817-9fa7-8e0894522e50.257727.443824
$ sudo tail -n 4 /var/log/apport.log
… INFO: apport (pid 257728) 2026-09-27 09:40:04,732: writing core dump to /var/lib/apport/coredump/core._home_deploy_dt_dt-parse.1001.60492fd2-965b-4817-9fa7-8e0894522e50.257727.443824 (limit: -1)

The core lands in /var/lib/apport/coredump, readable only by you (mode -r--------), named after the program, your UID, the boot ID and the PID. For programs from a package, apport instead writes a report to /var/crash/*.crash that contains the core; apport-unpack extracts it. Open the core with the program that produced it:

deploy@web01 · Ubuntu 26.04 LTS
$ gdb -batch -ex bt -ex "frame 1" -ex "print *h" ~/dt/dt-parse /var/lib/apport/coredump/core.*dt-parse*
… Core was generated by `/home/deploy/dt/dt-parse Host\ web01'. Program terminated with signal SIGSEGV, Segmentation fault. #0 __strlen_asimd () at ../sysdeps/aarch64/multiarch/strlen_asimd.S:95 … #0 __strlen_asimd () at ../sysdeps/aarch64/multiarch/strlen_asimd.S:95 #1 0x0000c9ab406f0900 in value_len (h=0xffffe21bec88) at dt-parse.c:12 #2 0x0000c9ab406f0978 in main (argc=2, argv=0xffffe21bee38) at dt-parse.c:23 #1 0x0000c9ab406f0900 in value_len (h=0xffffe21bec88) at dt-parse.c:12 12 return strlen(h->value); $1 = {name = 0xffffe21bfe74 "Host web01", value = 0x0}
$ sudo rm /var/lib/apport/coredump/core.*dt-parse*

gdb prints the frame where the program died as it loads the core, then bt prints the whole stack. The crash happened in the C library's strlen, called from value_len at line 12, and print *h shows value = 0x0: the NULL pointer. (gdb asks first whether to download debugging symbols from debuginfod.ubuntu.com and, reading no terminal, answers no; the line is left out.) Delete cores once you have what you need.

Core dumps contain secrets
A core holds everything the process had in memory: keys, tokens, passwords, other users' data. Keep them only as long as the analysis needs, restrict who can read them, and never attach one to a public bug report. For programs whose memory must never reach disk, a unit can set LimitCORE=0. Size matters too: a core is about as large as the process's memory, so LimitCORE=infinity on a service with a 40 GB heap writes 40 GB into /var while the dying process still holds its PID and port, which delays the restart. LimitCORE= also takes a byte value; size it to the disk.

RHEL sends every core to systemd-coredump, which stores it compressed in /var/lib/systemd/coredump, logs the crash with a backtrace in the journal, and lets you list and open cores with coredumpctl. Like apport, it keeps a core only when the process's core limit allows one, but on RHEL both SSH logins and services get an unlimited limit.

deploy@rocky10 · Rocky Linux 10.2
$ ulimit -c cat /proc/sys/kernel/core_pattern
unlimited |/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h %d %F
$ ~/dt/dt-parse 'Host web01'; echo "exit status $?"
-bash: line 1: 38287 Segmentation fault (core dumped) ~/dt/dt-parse 'Host web01' exit status 139
$ coredumpctl list --no-pager dt-parse
TIME PID UID GID SIG COREFILE EXE SIZE … Sun 2026-09-27 09:23:16 UTC 38287 1001 1001 SIGSEGV present /home/deploy/dt/dt-parse 15.6K
$ coredumpctl debug --no-pager --debugger-arguments="-batch -ex bt" dt-parse
PID: 38287 (dt-parse) UID: 1001 (deploy) GID: 1001 (deploy) Signal: 11 (SEGV) Timestamp: Sun 2026-09-27 09:23:16 UTC (371ms ago) Command Line: /home/deploy/dt/dt-parse $'Host web01' Executable: /home/deploy/dt/dt-parse … Storage: /var/lib/systemd/coredump/core.dt-parse.1001.69f6fa39e251416e8fb640040a028599.38287.1790500996000000.zst (present) Size on Disk: 15.6K Message: Process 38287 (dt-parse) of user 1001 dumped core. Stack trace of thread 38287: #0 0x0000ffff97b993d0 __strlen_asimd (libc.so.6 + 0xa93d0) #1 0x0000000000400724 value_len (/home/deploy/dt/dt-parse + 0x724) #2 0x0000000000400784 main (/home/deploy/dt/dt-parse + 0x784) #3 0x0000ffff97b1611c __libc_start_call_main (libc.so.6 + 0x2611c) #4 0x0000ffff97b161fc __libc_start_main@@GLIBC_2.34 (libc.so.6 + 0x261fc) #5 0x0000000000400630 _start (/home/deploy/dt/dt-parse + 0x630) … Core was generated by `/home/deploy/dt/dt-parse Host\ web01'. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x0000ffff97b993d0 in __strlen_asimd () from /lib64/libc.so.6 #0 0x0000ffff97b993d0 in __strlen_asimd () from /lib64/libc.so.6 #1 0x0000000000400724 in value_len (h=0xfffff4545518) at dt-parse.c:12 #2 0x0000000000400784 in main (argc=2, argv=0xfffff45456a8) at dt-parse.c:23
$ ls -l /var/lib/systemd/coredump/ | grep dt-parse
-rw-r-----+ 1 root root 15975 Sep 27 09:23 core.dt-parse.1001.69f6fa39e251416e8fb640040a028599.38287.1790500996000000.zst
$ systemd-analyze cat-config systemd/coredump.conf | grep -v "^#" | grep .
[Coredump] ProcessSizeMax=1G ExternalSizeMax=1G

Older entries from earlier runs are left out of the list; their files have since been removed. coredumpctl debug prints the journal record, including a stack trace systemd-coredump made at crash time, then starts gdb on the stored core (here in batch mode with bt). It shows the same NULL pointer path, without the C library's line numbers because its debug symbols are not installed. The file belongs to root, and the + after its mode marks an access control list, which is how coredumpctl could read it for you.

The last command shows the size limits from coredump.conf(5): upstream systemd allows 32G on 64-bit systems, and RHEL lowers both to 1G, so a larger core gets no stack trace (ProcessSizeMax=) and is not kept on disk (ExternalSizeMax=). Since every RHEL service may dump, check these limits, and MaxUse= for all cores together, before a large service crashes. Ubuntu can use systemd-coredump too: systemd-coredump and apport's apport-core-dump-handler both provide core-dump-handler and conflict, so installing the first replaces apport's pipe, and apport then builds its reports from systemd-coredump's records. On RHEL, ptrace_scope 0 also means gdb may attach to your own process without sudo, even one started from another shell:

deploy@rocky10 · Rocky Linux 10.2
$ sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 0
$ ~/dt/dt-worker > /dev/null 2>&1 &
$ gdb -p $(pgrep -x dt-worker) -batch -ex "info threads"
[New LWP 38132] [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib64/libthread_db.so.1". Id Target Id Frame * 1 Thread 0xffff9f241020 (LWP 38131) "dt-worker" 2 Thread 0xffff9f04f180 (LWP 38132) "flusher" [Inferior 1 (process 38131) detached]

The attach succeeded and listed both threads; the frame column is empty for the same CPU reason as on the Ubuntu lab.

Library calls: ltrace and its limits

ltrace shows the calls a program makes into shared libraries, which sit closer to the program's intent than system calls do. It works by planting breakpoints in the program's PLT, the table of stubs through which calls to shared libraries pass.

deploy@web01 · Ubuntu 26.04 LTS
$ ltrace ~/dt/dt-parse 'Host: web01'
__libc_start_main(["/home/deploy/dt/dt-parse", "Host: web01"] <unfinished ...> strchr("Host: web01", ':') = ": web01" strlen(" web01") = 6 printf("%zu\n", 6) = 2 __cxa_finalize(0xc43361c70008) = <void> 6 +++ exited (status 0) +++

For the lab's own program, the output is right: strchr found the colon, strlen measured six characters, printf wrote two bytes. Now a program from the distribution:

deploy@web01 · Ubuntu 26.04 LTS
$ ltrace hostname
fileno(0xc35743db1680 <unfinished ...> fwrite("hostname", 47, 281474010071848, 0xc35743db1680) = 0 … errx(1, 0xffffc6624318, 0xc35743db29d0, 0xc35743dcfa40) = 0xffffffff … setdomainname(0xc3577ce12010, 0, 128, 0xc3577ce12010) = 6 fopen("\b", "?#\003\325\375{\276\251\375\003") = 0x1 web01 +++ exited (status 0) +++

hostname printed web01 and exited with status 0, yet ltrace reports errx(1, ...), which would have ended it with status 1, and setdomainname, which it never calls. ltrace matched the PLT entries of this distribution binary to the wrong names on aarch64. Upstream ltrace went twelve years without a release after 0.7.3 (2013), until 0.8.0 and 0.8.1 in September 2025; both lab systems still ship patched 0.7.91 code (a 2023 git snapshot on Ubuntu 26.04, 0.7.91 on RHEL 10), and that is what produced this output. Treat its output as a hint to confirm, not as evidence. For library calls you can trust, use uprobes: bpftrace can count or time any function in libc.so.6 without stopping the process (see the eBPF tools lesson), and perf probe -x does the same with perf.

Try this

Fix the deadlock and prove the fix. Copy dt-worker.c to dt-worker-fixed.c, change flush_queue so it takes stats_lock first and queue_lock second (the same order as collect_stats), and build it. Then run both versions under strace for ten seconds, counting sleeps: strace -f -c -e trace=clock_nanosleep timeout 10 ~/dt/dt-worker and the same for dt-worker-fixed. strace is allowed without sudo because it starts the program, so it is an ancestor. Predict before you run: the deadlocked version sleeps twice (once per thread) and never again; the fixed one keeps working and sleeps about once a second (9 calls in the lab run's ten seconds; when timeout happens to interrupt a sleep, that call is counted as an error).

Takeaway

Look from outside first (ps -L, wchan), attach with a one-off sudo rather than weakening ptrace_scope, and detach promptly; when you cannot hold a process stopped, work from a core, which means knowing where your platform sends cores and what core limit your services run with.

Quick check
01On Ubuntu 26.04 you run gdb -p against a hung service that runs under your own account, and gdb says it could not attach. What is the right next step?
Correct — The refusal comes from ptrace_scope 1, which a privileged tracer bypasses, so nothing host-wide changes.
Incorrect — It works, but for that window every process on the host can read same-user processes' memory; sudo avoids the exposure.
Incorrect — The debugger must be the ancestor, not the shell; restarting also destroys the hung state you wanted to examine.
Incorrect — adm grants read access to logs. No group membership affects the ptrace scope check.
02A service on Ubuntu crashes with SIGSEGV. The journal shows it dumped core, but /var/lib/apport/coredump and /var/crash are empty. The binary was built in-house and installed into /opt. What explains it?
Incorrect — For a pipe the kernel ignores RLIMIT_CORE and runs the handler anyway, which is why the journal says it dumped.
Incorrect — apport handles services too; in the lab it wrote a service's core once LimitCORE allowed one.
Correct — Services on Ubuntu have a soft core limit of 0, so set LimitCORE= on the unit to keep cores.
Incorrect — apport's pipe replaces that value at boot; a plain core file appears only if something reset core_pattern.
03ltrace on a distribution binary shows a call to exit(1) in the middle of a run, yet the program finishes normally and returns 0. What is the most reasonable reading?
Incorrect — exit() does not return and cannot be caught; a program that called it would have ended with status 1.
Incorrect — ltrace follows children only with -f; without it the calls shown are those of the traced program itself.
Incorrect — A call to exit(1) ends the process wherever it happens, including in a constructor.
Correct — ltrace's PLT matching is fragile on modern binaries; a contradiction with the exit status shows it.

Related