在芯片研发环境里,很多 EDA 软件并不直接运行在用户当前的桌面机上。常见模式是:

  • 用户登录一台 CentOS 7.9 Xfce4 桌面机;
  • 实际的 EDA 软件运行在另一台 CentOS 7.9 执行机;
  • 软件窗口通过 X11 显示回用户桌面。

这种模式很常见,也很实用。但它带来一个运维问题:当用户桌面上出现一个软件窗口时,如何快速判断这个窗口来自哪台执行机、对应远端哪个进程?

如果只看本地桌面机,很容易误判。因为窗口显示在本地,但进程实际运行在远端执行机上。本文给出一种基于 xpropWM_CLIENT_MACHINE_NET_WM_PID 和 SSH 的方法,并封装成一个可以放在桌面上双击使用的 Python3 工具。

一、问题背景

在 X11 模式下,远端程序会把窗口注册到本地 X server。对于这些窗口,通常可以通过窗口属性看到两个关键字段:

1
2
WM_CLIENT_MACHINE(STRING) = "icinfra0012"
_NET_WM_PID(CARDINAL) = 238741

含义是:

字段 含义
WM_CLIENT_MACHINE 创建该窗口的客户端机器名
_NET_WM_PID 创建该窗口的远端进程 PID
WM_CLASS 窗口类别,常用于识别程序类型
WM_NAME 窗口标题
WM_COMMAND 程序启动命令,取决于程序是否设置该属性

最直接的手工方式是在桌面机上执行:

1
xprop

然后点击目标窗口。如果窗口属性中有 WM_CLIENT_MACHINE_NET_WM_PID,就可以继续到远端执行机上查:

1
2
ssh icinfra0012 "ps -fp 238741"
ssh icinfra0012 "pstree -aps 238741"

手工方式适合管理员排查,不适合普通用户。更好的方式是把这些动作封装成一个桌面工具:

  1. 用户双击桌面图标;
  2. 用户点击目标窗口;
  3. 工具自动读取窗口属性;
  4. 工具 SSH 到远端执行机;
  5. 工具生成一个 HTML 报告;
  6. 报告里展示父进程链和子进程树,并支持展开/折叠。

二、整体实现思路

脚本的整体流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户双击桌面图标
    ↓
调用 xprop,让用户点击目标窗口
    ↓
解析 WM_CLIENT_MACHINE 与 _NET_WM_PID
    ↓
通过 SSH 登录远端执行机
    ↓
远端执行 ps 获取完整进程表
    ↓
本地解析 pid / ppid 关系
    ↓
生成 HTML 树形报告
    ↓
用浏览器打开报告

这里没有依赖远端 Python。远端只需要具备常见 Linux 命令:

  • ps
  • pstree,可选,用于原始 pstree 输出

树形 HTML 由本地 Python3 生成,因此普通用户看到的是一个可以展开/折叠的页面。

三、csh/tcsh 默认 Shell 的兼容性问题

在一些 EDA 环境中,用户默认 shell 可能是 cshtcsh。这时如果远端命令直接写成:

1
LC_ALL=C ps -ww -eo pid=,ppid=,user=,stat=,comm=,args=

会报错:

1
LC_ALL=C: Command not found.

原因是 LC_ALL=C commandsh/bash 风格的临时环境变量写法,csh/tcsh 不这样解析。

因此,脚本里所有远端命令都统一通过:

1
/bin/sh -lc '...'

执行。这样无论远端用户默认 shell 是 bashshcsh 还是 tcsh,实际命令都由 /bin/sh 解释。

脚本中的关键封装是:

1
2
def remote_sh(command):
    return "/bin/sh -lc {}".format(shlex.quote(command))

四、完整 Python3 脚本

建议保存为:

1
~/bin/x11_window_proc_tree.py

完整脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
X11 Remote Window Process Tree Viewer

适用场景:
    CentOS 7.9 Xfce4 桌面机
    +
    CentOS 7.9 执行机上的 X11 GUI 软件窗口弹到桌面机

功能:
    1. 用户点击目标 X11 窗口
    2. 通过 xprop 获取:
        - WM_CLIENT_MACHINE
        - _NET_WM_PID
        - WM_NAME
        - WM_CLASS
        - WM_COMMAND
    3. SSH 到 WM_CLIENT_MACHINE 对应机器
    4. 远端执行 ps,获取全量进程表
    5. 本地生成以 _NET_WM_PID 为根的可展开 / 折叠 HTML 进程树
    6. 自动打开 HTML 报告

重要兼容性:
    远端默认 shell 可能是 bash/sh,也可能是 csh/tcsh。
    所有远端命令都会强制通过 /bin/sh -lc 执行,
    避免 csh/tcsh 不识别:
        LC_ALL=C command
        if ...; then ...; fi
        cmd1 || cmd2

本地依赖:
    python3
    xprop
    ssh
    xdg-open 可选
    zenity / xmessage 可选

远端依赖:
    ps
    pstree 可选
"""

import argparse
import datetime
import html
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import webbrowser
from collections import defaultdict


DEFAULT_SSH_CONNECT_TIMEOUT = 8

# yes: 适合已经配置 SSH 免密的环境
# no : 允许 SSH 密码交互,但双击 GUI 场景下不一定稳定
DEFAULT_SSH_BATCH_MODE = "yes"

MAX_TREE_NODES = 8000

HTML_OUTPUT_DIR = "/tmp"


def command_exists(cmd):
    return shutil.which(cmd) is not None


def show_info(message, title="X11 Process Tree"):
    if command_exists("zenity"):
        subprocess.call([
            "zenity",
            "--info",
            "--title", title,
            "--text", message,
            "--width", "600",
        ])
    elif command_exists("xmessage"):
        subprocess.call([
            "xmessage",
            "-center",
            message,
        ])
    else:
        print(message)


def show_error(message, title="X11 Process Tree Error"):
    if command_exists("zenity"):
        subprocess.call([
            "zenity",
            "--error",
            "--title", title,
            "--text", message,
            "--width", "760",
        ])
    elif command_exists("xmessage"):
        subprocess.call([
            "xmessage",
            "-center",
            "ERROR:\n\n" + message,
        ])
    else:
        print("ERROR:", message, file=sys.stderr)


def run_local_command(cmd, timeout=None):
    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        universal_newlines=True,
    )

    try:
        out, err = proc.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        proc.kill()
        out, err = proc.communicate()
        raise RuntimeError("Command timeout: {}".format(" ".join(cmd)))

    return proc.returncode, out, err


def parse_xprop_value(line):
    if "=" not in line:
        return ""

    value = line.split("=", 1)[1].strip()

    if value.startswith('"') and value.endswith('"') and value.count('"') == 2:
        return value[1:-1]

    return value


def get_xprop_from_clicked_window():
    show_info(
        "请点击需要定位的 X11 软件窗口。\n\n"
        "脚本会读取:\n"
        "  WM_CLIENT_MACHINE\n"
        "  _NET_WM_PID\n\n"
        "然后 SSH 到对应执行机生成进程树。"
    )

    rc, out, err = run_local_command(["xprop"])

    if rc != 0:
        raise RuntimeError(
            "xprop 执行失败或用户取消。\n\n{}".format(err.strip())
        )

    props = {}

    for line in out.splitlines():
        line = line.strip()

        if line.startswith("WM_CLIENT_MACHINE"):
            props["WM_CLIENT_MACHINE"] = parse_xprop_value(line)

        elif line.startswith("_NET_WM_PID"):
            props["_NET_WM_PID"] = parse_xprop_value(line)

        elif line.startswith("WM_NAME"):
            props["WM_NAME"] = parse_xprop_value(line)

        elif line.startswith("WM_CLASS"):
            props["WM_CLASS"] = parse_xprop_value(line)

        elif line.startswith("WM_COMMAND"):
            props["WM_COMMAND"] = parse_xprop_value(line)

    props["_RAW_XPROP"] = out

    return props


def normalize_hostname(machine):
    return machine.strip().strip('"').strip("'")


def normalize_pid(pid_text):
    pid_text = pid_text.strip()

    m = re.search(r"\d+", pid_text)
    if not m:
        raise ValueError("_NET_WM_PID 不是合法 PID: {}".format(pid_text))

    return int(m.group(0))


def remote_sh(command):
    """
    强制远端命令通过 /bin/sh 执行。

    这样即使远端用户默认 shell 是 csh/tcsh,也可以正确执行:
        LC_ALL=C command
        if ...; then ...; fi
        cmd1 || cmd2
        command -v xxx
    """
    return "/bin/sh -lc {}".format(shlex.quote(command))


def ssh_command(machine, remote_command, connect_timeout, batch_mode):
    ssh_cmd = [
        "ssh",
        "-o", "ConnectTimeout={}".format(connect_timeout),
        "-o", "BatchMode={}".format(batch_mode),
        machine,
        remote_command,
    ]

    return run_local_command(ssh_cmd)


def fetch_remote_hostname(machine, connect_timeout, batch_mode):
    remote_cmd = remote_sh(
        "hostname 2>/dev/null || uname -n 2>/dev/null || echo unknown"
    )

    rc, out, err = ssh_command(
        machine,
        remote_cmd,
        connect_timeout,
        batch_mode,
    )

    if rc != 0:
        return "unknown"

    return out.strip() or "unknown"


def fetch_remote_process_table(machine, connect_timeout, batch_mode):
    remote_cmd = remote_sh(
        "LC_ALL=C ps -ww -eo pid=,ppid=,user=,stat=,comm=,args="
    )

    rc, out, err = ssh_command(
        machine,
        remote_cmd,
        connect_timeout,
        batch_mode,
    )

    if rc != 0:
        raise RuntimeError(
            "远端 ps 执行失败。\n\n"
            "machine: {}\n\n"
            "stderr:\n{}".format(machine, err.strip())
        )

    return out


def fetch_remote_pstree(machine, pid, connect_timeout, batch_mode):
    remote_cmd = remote_sh(
        "if command -v pstree >/dev/null 2>&1; then "
        "pstree -aps {} 2>&1; "
        "else "
        "echo 'pstree command not found on remote host'; "
        "fi".format(int(pid))
    )

    rc, out, err = ssh_command(
        machine,
        remote_cmd,
        connect_timeout,
        batch_mode,
    )

    if rc != 0:
        text = (out + "\n" + err).strip()
        if not text:
            text = "pstree failed"
        return text

    return out.strip()


def parse_ps_output(ps_output):
    procs = {}

    for raw_line in ps_output.splitlines():
        line = raw_line.strip()
        if not line:
            continue

        parts = line.split(None, 5)

        if len(parts) < 5:
            continue

        try:
            pid = int(parts[0])
            ppid = int(parts[1])
        except ValueError:
            continue

        user = parts[2]
        stat = parts[3]
        comm = parts[4]
        args = parts[5] if len(parts) >= 6 else "[{}]".format(comm)

        procs[pid] = {
            "pid": pid,
            "ppid": ppid,
            "user": user,
            "stat": stat,
            "comm": comm,
            "args": args,
        }

    children = defaultdict(list)

    for pid, info in procs.items():
        children[info["ppid"]].append(pid)

    for ppid in children:
        children[ppid].sort()

    return procs, children


def build_ancestor_chain(target_pid, procs):
    chain = []
    seen = set()
    current = target_pid

    while current in procs and current not in seen:
        chain.append(current)
        seen.add(current)

        ppid = procs[current]["ppid"]

        if ppid <= 0:
            break

        current = ppid

    chain.reverse()

    return chain


def h(value):
    return html.escape(str(value), quote=True)


def proc_summary(pid, procs, children, target_pid):
    info = procs.get(pid)

    if not info:
        return (
            '<span class="pid">PID {}</span> '
            '<span class="bad">not found</span>'
        ).format(h(pid))

    css_class = "proc target" if pid == target_pid else "proc"
    child_count = len(children.get(pid, []))

    return (
        '<span class="{css_class}">'
        '<span class="pid">PID {pid}</span> '
        '<span class="ppid">PPID {ppid}</span> '
        '<span class="user">USER {user}</span> '
        '<span class="stat">STAT {stat}</span> '
        '<span class="comm">COMM {comm}</span> '
        '<span class="child-count">CHILDREN {child_count}</span> '
        '<span class="cmd">{args}</span>'
        '</span>'
    ).format(
        css_class=css_class,
        pid=h(info["pid"]),
        ppid=h(info["ppid"]),
        user=h(info["user"]),
        stat=h(info["stat"]),
        comm=h(info["comm"]),
        child_count=h(child_count),
        args=h(info["args"]),
    )


def render_process_node(
    pid,
    procs,
    children,
    target_pid,
    depth=0,
    visited=None,
    state=None,
):
    if visited is None:
        visited = set()

    if state is None:
        state = {"count": 0}

    state["count"] += 1

    if state["count"] > MAX_TREE_NODES:
        return '<div class="warn">Node limit reached, output truncated.</div>'

    if pid in visited:
        return (
            '<details>'
            '<summary>{} <span class="bad">cycle detected</span></summary>'
            '</details>'
        ).format(proc_summary(pid, procs, children, target_pid))

    visited.add(pid)

    child_pids = children.get(pid, [])

    open_attr = " open" if depth <= 1 else ""

    parts = []
    parts.append("<details{}>".format(open_attr))
    parts.append("<summary>{}</summary>".format(
        proc_summary(pid, procs, children, target_pid)
    ))

    for child_pid in child_pids:
        parts.append(render_process_node(
            child_pid,
            procs,
            children,
            target_pid,
            depth=depth + 1,
            visited=visited,
            state=state,
        ))

    parts.append("</details>")

    visited.remove(pid)

    return "\n".join(parts)


def generate_html_report(
    machine,
    remote_hostname,
    target_pid,
    xprops,
    procs,
    children,
    pstree_text,
):
    generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    title = "X11 Remote Process Tree - {}:{}".format(machine, target_pid)

    wm_name = xprops.get("WM_NAME", "")
    wm_class = xprops.get("WM_CLASS", "")
    wm_command = xprops.get("WM_COMMAND", "")
    raw_xprop = xprops.get("_RAW_XPROP", "")

    if target_pid in procs:
        target_tree_html = render_process_node(
            target_pid,
            procs,
            children,
            target_pid,
        )
    else:
        target_tree_html = (
            '<div class="bad">'
            'Target PID {} not found on remote host. '
            'The process may have already exited.'
            '</div>'
        ).format(h(target_pid))

    ancestor_chain = build_ancestor_chain(target_pid, procs)

    if ancestor_chain:
        ancestor_html_list = []

        for pid in ancestor_chain:
            ancestor_html_list.append(
                '<div class="ancestor-item">{}</div>'.format(
                    proc_summary(pid, procs, children, target_pid)
                )
            )

        ancestor_html = "\n".join(ancestor_html_list)
    else:
        ancestor_html = (
            '<div class="bad">No ancestor chain found for PID {}.</div>'
        ).format(h(target_pid))

    return """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{title}</title>
<style>
body 
h1 
h2 
.card 
.meta-table 
.meta-table td 
.meta-table td:first-child 
button 
button:hover 
details 
summary 
.proc 
.target 
.pid 
.ppid 
.user 
.stat 
.comm 
.child-count 
.cmd 
.small 
.bad 
.warn 
pre 
.ancestor-item 
</style>
<script>
function setAllDetails(openState) 
}}
</script>
</head>
<body>

<h1>{title}</h1>
<div class="small">Generated at {generated_at}</div>

<h2>Window And Remote Process Metadata</h2>
<div class="card">
<table class="meta-table">
<tr><td>WM_CLIENT_MACHINE from xprop</td><td><code>{machine}</code></td></tr>
<tr><td>Remote hostname from ssh</td><td><code>{remote_hostname}</code></td></tr>
<tr><td>_NET_WM_PID from xprop</td><td><code>{target_pid}</code></td></tr>
<tr><td>WM_NAME</td><td><code>{wm_name}</code></td></tr>
<tr><td>WM_CLASS</td><td><code>{wm_class}</code></td></tr>
<tr><td>WM_COMMAND</td><td><code>{wm_command}</code></td></tr>
</table>
</div>

<h2>Ancestor Chain</h2>
<div class="card">
{ancestor_html}
</div>

<h2>Descendant Tree Rooted At Target PID</h2>
<div class="card">
<button onclick="setAllDetails(true)">全部展开</button>
<button onclick="setAllDetails(false)">全部折叠</button>
</div>

<div class="card">
{target_tree_html}
</div>

<h2>Raw pstree Output</h2>
<div class="card">
<pre>{pstree_text}</pre>
</div>

<h2>Raw xprop Output</h2>
<div class="card">
<pre>{raw_xprop}</pre>
</div>

</body>
</html>
""".format(
        title=h(title),
        generated_at=h(generated_at),
        machine=h(machine),
        remote_hostname=h(remote_hostname),
        target_pid=h(target_pid),
        wm_name=h(wm_name),
        wm_class=h(wm_class),
        wm_command=h(wm_command),
        ancestor_html=ancestor_html,
        target_tree_html=target_tree_html,
        pstree_text=h(pstree_text),
        raw_xprop=h(raw_xprop),
    )


def write_html_report(machine, pid, html_text):
    safe_machine = re.sub(r"[^A-Za-z0-9_.-]+", "_", machine)
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")

    filename = "x11_proc_tree_{}_{}_{}.html".format(
        safe_machine,
        pid,
        timestamp,
    )

    path = os.path.join(HTML_OUTPUT_DIR, filename)

    with open(path, "w", encoding="utf-8") as f:
        f.write(html_text)

    return path


def open_report(path):
    if command_exists("xdg-open"):
        subprocess.Popen(
            ["xdg-open", path],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
    else:
        webbrowser.open("file://" + os.path.abspath(path))


def main():
    parser = argparse.ArgumentParser(
        description="Pick an X11 window and show remote process tree."
    )

    parser.add_argument(
        "--ssh-connect-timeout",
        type=int,
        default=DEFAULT_SSH_CONNECT_TIMEOUT,
        help="SSH connect timeout in seconds.",
    )

    parser.add_argument(
        "--ssh-batch-mode",
        choices=["yes", "no"],
        default=DEFAULT_SSH_BATCH_MODE,
        help="SSH BatchMode. yes means no password prompt.",
    )

    parser.add_argument(
        "--no-open",
        action="store_true",
        help="Only generate HTML report, do not open it.",
    )

    parser.add_argument(
        "--machine",
        default=None,
        help="Debug mode: skip xprop and use this remote machine.",
    )

    parser.add_argument(
        "--pid",
        type=int,
        default=None,
        help="Debug mode: skip xprop and use this PID.",
    )

    args = parser.parse_args()

    for cmd in ["xprop", "ssh"]:
        if not command_exists(cmd):
            show_error("缺少命令:{}\n\n请先安装。".format(cmd))
            return 1

    try:
        if args.machine and args.pid:
            machine = normalize_hostname(args.machine)
            target_pid = int(args.pid)
            xprops = {
                "WM_CLIENT_MACHINE": machine,
                "_NET_WM_PID": str(target_pid),
                "WM_NAME": "",
                "WM_CLASS": "",
                "WM_COMMAND": "",
                "_RAW_XPROP": "Debug mode: xprop skipped.",
            }
        else:
            xprops = get_xprop_from_clicked_window()

            raw_machine = xprops.get("WM_CLIENT_MACHINE", "")
            raw_pid = xprops.get("_NET_WM_PID", "")

            if not raw_machine or not raw_pid:
                fd, debug_path = tempfile.mkstemp(
                    prefix="x11_window_props_debug_",
                    suffix=".txt",
                    dir="/tmp",
                )

                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    f.write(xprops.get("_RAW_XPROP", ""))

                raise RuntimeError(
                    "没有从该窗口读取到 WM_CLIENT_MACHINE 或 _NET_WM_PID。\n\n"
                    "可能原因:\n"
                    "1. 点击的不是目标软件窗口;\n"
                    "2. 点击到了窗口装饰层或桌面背景;\n"
                    "3. 该程序没有设置 _NET_WM_PID;\n"
                    "4. 该窗口来自特殊 wrapper、Java、老 Motif 程序。\n\n"
                    "xprop 原始输出已保存:\n{}".format(debug_path)
                )

            machine = normalize_hostname(raw_machine)
            target_pid = normalize_pid(raw_pid)

        remote_hostname = fetch_remote_hostname(
            machine,
            args.ssh_connect_timeout,
            args.ssh_batch_mode,
        )

        ps_output = fetch_remote_process_table(
            machine,
            args.ssh_connect_timeout,
            args.ssh_batch_mode,
        )

        pstree_text = fetch_remote_pstree(
            machine,
            target_pid,
            args.ssh_connect_timeout,
            args.ssh_batch_mode,
        )

        procs, children = parse_ps_output(ps_output)

        html_report = generate_html_report(
            machine=machine,
            remote_hostname=remote_hostname,
            target_pid=target_pid,
            xprops=xprops,
            procs=procs,
            children=children,
            pstree_text=pstree_text,
        )

        report_path = write_html_report(machine, target_pid, html_report)

        if not args.no_open:
            open_report(report_path)

        show_info(
            "已生成进程树报告:\n\n"
            "{}\n\n"
            "窗口来源:\n"
            "  WM_CLIENT_MACHINE = {}\n"
            "  _NET_WM_PID       = {}".format(
                report_path,
                machine,
                target_pid,
            )
        )

        return 0

    except Exception as e:
        show_error(str(e))
        return 1


if __name__ == "__main__":
    sys.exit(main())

五、安装依赖

桌面机需要:

1
sudo yum install -y xorg-x11-utils xdg-utils openssh-clients

如果希望有图形弹窗提示,建议安装:

1
sudo yum install -y zenity

远端执行机建议安装:

1
sudo yum install -y psmisc

psmisc 提供 pstree。如果远端没有 pstree,脚本仍然可以通过 ps 生成 HTML 进程树,只是 Raw pstree Output 区域会提示没有 pstree

六、部署脚本

将脚本保存到:

1
2
3
mkdir -p ~/bin
vi ~/bin/x11_window_proc_tree.py
chmod +x ~/bin/x11_window_proc_tree.py

命令行测试:

1
~/bin/x11_window_proc_tree.py

也可以跳过 xprop,直接指定远端机器和 PID 做调试:

1
~/bin/x11_window_proc_tree.py --machine icinfra0012 --pid 238741

七、创建桌面双击入口

在 Xfce4 桌面机上创建 .desktop 文件:

1
2
3
4
5
6
7
8
9
10
11
12
cat > ~/Desktop/X11-Window-Process-Tree.desktop <<EOF
[Desktop Entry]
Type=Application
Name=X11 Window Process Tree
Comment=Pick an X11 window and show remote process tree
Exec=$HOME/bin/x11_window_proc_tree.py
Icon=utilities-system-monitor
Terminal=false
Categories=Utility;
EOF

chmod +x ~/Desktop/X11-Window-Process-Tree.desktop

如果 Xfce 提示该启动器不可信,可以右键桌面图标,选择类似:

1
Allow Launching

或:

1
Mark as Trusted

不同版本的 Xfce 显示会略有差异。

八、SSH 免密建议

脚本默认使用:

1
BatchMode=yes

这意味着 SSH 不会弹出密码输入提示。这个设置适合已经配置 SSH key、Kerberos 或其他免密登录的研发环境。

如果环境暂时没有 SSH 免密,可以在命令行中测试:

1
~/bin/x11_window_proc_tree.py --ssh-batch-mode no

如果要让桌面图标允许密码交互,可以把 .desktop 改成:

1
2
Terminal=true
Exec=/home/your_user/bin/x11_window_proc_tree.py --ssh-batch-mode no

更推荐的方式仍然是配置免密登录:

1
ssh-copy-id icinfra0012

九、验证 csh/tcsh 兼容性

如果远端用户默认 shell 是 cshtcsh,可以先在桌面机上执行:

1
ssh icinfra0012 "/bin/sh -lc 'LC_ALL=C ps -ww -eo pid=,ppid=,user=,stat=,comm=,args= | head'"

如果能正常输出进程列表,就说明 /bin/sh -lc 的兼容性处理有效。

常见错误如下:

1
LC_ALL=C: Command not found.

这个错误说明远端命令被 csh/tcsh 直接解释了。脚本中通过 /bin/sh -lc 统一处理后,就不会再触发该问题。

十、使用效果

用户双击桌面图标后,脚本会提示用户点击目标窗口。点击后会自动生成一个 HTML 文件,例如:

1
/tmp/x11_proc_tree_icinfra0012_238741_20260520_110000.html

报告中包含:

  • WM_CLIENT_MACHINE
  • _NET_WM_PID
  • WM_NAME
  • WM_CLASS
  • WM_COMMAND
  • 远端 hostname
  • 父进程链
  • 以目标 PID 为根的子进程树
  • 原始 pstree -aps 输出
  • 原始 xprop 输出

其中进程树部分使用 HTML <details> 标签实现,浏览器中可以直接展开和折叠。

十一、适用边界

这个方法依赖窗口自身提供的 X11 属性。对于大多数标准 X11 GUI 程序,WM_CLIENT_MACHINE_NET_WM_PID 都能正常读取。

以下场景需要注意:

  1. 某些老 Motif 程序可能不设置 _NET_WM_PID
  2. 某些 Java GUI 程序的 PID 可能是 wrapper 或 launcher;
  3. 如果进程已经退出,远端 ps 中可能找不到该 PID;
  4. 如果点击到窗口装饰层或桌面背景,xprop 结果可能不包含目标窗口属性;
  5. 如果 SSH 无法从桌面机登录执行机,脚本无法继续查询进程树。

在 EDA 研发环境中,这个工具可以作为一线支持排障入口:用户无需理解 X11、PID、SSH 和 pstree,只需要双击工具并点击目标窗口,就能生成一份相对完整的远端进程报告。