Repository: pwndbg/pwndbg
Pull request: Enhance thread context
This PR makes the following changes to the "threads" section of the `context` command's output:
* Add total thread count to banner
* Limit number of threads displayed
* Add `context-max-threads` config variable
* No longer attempt to get frames from running threads (causes an error)

Old behaviour:
![ctx-threads-old](https://github.com/pwndbg/pwndbg/assets/16000770/e8882262-14c4-4750-834c-61b963ffaf2e)

New behaviour:
![ctx-threads-new](https://github.com/pwndbg/pwndbg/assets/16000770/205128e8-965a-4672-97b5-30e7df5dd201)
![ctx-threads-new-running](https://github.com/pwndbg/pwndbg/assets/16000770/2d1cadc1-ef4e-407a-adc6-a8a595224730)

Resolves #1904 

Problem statement:
Enhance thread context
The thread context can be improved so that:
1. It shows the number of all threads in the top bar e..g `[ THREADS (20) ]`
2. It limits the number of displayed threads via some config variable
* When there are more threads then the limit, it should display current thread and other threads up to the limit and the following text: "... (use `ctx thread` or `set context-threads-limit <N>`)"

Hints from issue discussion:
Impl details:
* https://github.com/pwndbg/pwndbg/blob/dev/pwndbg/commands/context.py#L853-L897
* https://github.com/pwndbg/pwndbg/blob/dev/pwndbg/commands/context.py#L620C5-L627
Any objections to using the tabulate library to display this information?
Nope, it probably makes sense to use it

Commit message: Add context-max-threads config

Change the number of threads displayed by the `context` command.
Use `set context-max-threads` to change this value, defaults to 4.
Add a total threads count to the banner.

Patch to review:
diff --git a/pwndbg/commands/context.py b/pwndbg/commands/context.py
index 82f23715805..c6b4c181a96 100644
--- a/pwndbg/commands/context.py
+++ b/pwndbg/commands/context.py
@@ -81,6 +81,11 @@ def clear_screen(out=sys.stdout) -> None:
     "regs disasm code ghidra stack backtrace expressions threads",
     "which context sections are displayed (controls order)",
 )
+config_max_threads_display = pwndbg.gdblib.config.add_param(
+    "context-max-threads",
+    4,
+    "maximum number of threads displayed by the context command",
+)
 
 # Storing output configuration per section
 outputs: dict[str, str] = {}
@@ -851,48 +856,57 @@ def get_thread_status(thread):
 
 
 def context_threads(with_banner=True, target=sys.stdout, width=None):
-    threads = gdb.selected_inferior().threads()[::-1]
+    all_threads = gdb.selected_inferior().threads()[::-1]
+    displayed_threads = all_threads[: int(config_max_threads_display)]
+    num_threads_not_shown = len(all_threads) - len(displayed_threads)
 
-    if len(threads) < 2:
+    if len(displayed_threads) < 2:
         return []
 
-    selected_thread = gdb.selected_thread()
-    selected_frame = gdb.selected_frame()
+    original_thread = gdb.selected_thread()
 
-    out = []
+    out = [pwndbg.ui.banner(f"threads ({len(all_threads)} total)", target=target, width=width)]
     max_name_length = 0
 
-    for thread in threads:
+    for thread in displayed_threads:
         name = thread.name or ""
         if len(name) > max_name_length:
             max_name_length = len(name)
 
-    for thread in threads:
-        thread.switch()
-        frame = gdb.selected_frame()
-
-        selected = " ►" if thread is selected_thread else "  "
-
-        symbol = pwndbg.gdblib.symbol.get(frame.pc())
-        status = get_thread_status(thread)
-
+    for thread in filter(lambda t: t.is_valid(), displayed_threads):
+        selected = " ►" if thread is original_thread else "  "
         name = thread.name if thread.name is not None else ""
         padding = max_name_length - len(name)
+        status = get_thread_status(thread)
 
         line = (
             f" {selected} {thread.global_num}\t"
             f'"{pwndbg.color.cyan(name)}" '
             f'{" " * padding}'
-            f"{status}: {M.get(frame.pc())}"
+            f"{status}: "
         )
-        if symbol:
-            line += f" <{pwndbg.color.bold(pwndbg.color.green(symbol))}> "
+
+        if thread.is_stopped():
+            thread.switch()
+            frame = gdb.selected_frame()
+
+            pc = M.get(frame.pc())
+            symbol = pwndbg.gdblib.symbol.get(frame.pc())
+
+            line += f"{pc}"
+            if symbol:
+                line += f" <{pwndbg.color.bold(pwndbg.color.green(symbol))}> "
+
         out.append(line)
 
-    out.insert(0, pwndbg.ui.banner("threads", target=target, width=width))
+    if num_threads_not_shown:
+        out.append(
+            pwndbg.lib.tips.color_tip(
+                f"Not showing {num_threads_not_shown} threads. Use `set context-max-threads <number of threads>` to change this."
+            )
+        )
 
-    selected_thread.switch()
-    selected_frame.select()
+    original_thread.switch()
 
     return out
 


Code review comments:
diff_hunk: @@ -851,48 +856,57 @@ def get_thread_status(thread):
 
 
 def context_threads(with_banner=True, target=sys.stdout, width=None):
-    threads = gdb.selected_inferior().threads()[::-1]
+    all_threads = gdb.selected_inferior().threads()[::-1]
+    displayed_threads = all_threads[: int(config_max_threads_display)]
+    num_threads_not_shown = len(all_threads) - len(displayed_threads)
 
-    if len(threads) < 2:
+    if len(displayed_threads) < 2:
         return []
 
-    selected_thread = gdb.selected_thread()
-    selected_frame = gdb.selected_frame()
+    original_thread = gdb.selected_thread()
 
-    out = []
+    out = [pwndbg.ui.banner(f"threads ({len(all_threads)} total)", target=target, width=width)]
     max_name_length = 0
 
-    for thread in threads:
+    for thread in displayed_threads:
         name = thread.name or ""
         if len(name) > max_name_length:
             max_name_length = len(name)
 
-    for thread in threads:
-        thread.switch()
-        frame = gdb.selected_frame()
-
-        selected = " ►" if thread is selected_thread else "  "
-
-        symbol = pwndbg.gdblib.symbol.get(frame.pc())
-        status = get_thread_status(thread)
-
+    for thread in filter(lambda t: t.is_valid(), displayed_threads):
+        selected = " ►" if thread is original_thread else "  "
         name = thread.name if thread.name is not None else ""
         padding = max_name_length - len(name)
+        status = get_thread_status(thread)
 
         line = (
             f" {selected} {thread.global_num}\t"
             f'"{pwndbg.color.cyan(name)}" '
             f'{" " * padding}'
-            f"{status}: {M.get(frame.pc())}"
+            f"{status}: "
         )
-        if symbol:
-            line += f" <{pwndbg.color.bold(pwndbg.color.green(symbol))}> "
+
+        if thread.is_stopped():
+            thread.switch()
+            frame = gdb.selected_frame()
+
+            pc = M.get(frame.pc())
+            symbol = pwndbg.gdblib.symbol.get(frame.pc())
path: pwndbg/commands/context.py
text: @user1:
Maybe:
```py
pc = gdb.selected_frame().pc()
pc, symbol = M.get(pc), pwndbg.gdblib.symbol.get(pc)
```

@author:
Why 🤔

@author:
Ah I assume you want to remove the redundant call to `frame.pc()` 👍