summaryrefslogtreecommitdiffstats
path: root/tools/guest-diag/guest_mode.sh
blob: 24601310050adbc8d7039ff5e0d638fe2ee35592 (plain)
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
#!/bin/bash
# guest_mode.sh - read the NVIDIA X driver's nvClip screens[0].modeNum.
#
# The X driver (nvidia_drv.so) creates the nvClip shared area through its
# anonymous-file helper: memfd_create first (name derived from
# "/.nvidia_drv.XXXXXX"), then O_TMPFILE, then mkstemp+unlink.  570.144 size
# is 0x245000 bytes.  The fd is handed to GLX clients over X SEND_FDS.
# Layout (570.144, verified against nvidia_drv.so 570.144):
#   +0x00 u32 grabbed
#   +0x04 u32 token              (unique X-server token, sanity check)
#   +0x08 u32 screens[0].modeNum <-- this script
#   screens[] stride 0x84 (132) bytes
#
# modeNum parity is the GL client's __GL_DEV_MODESWITCH_TEST_COMPLETE bit:
#   even = device enabled / mode switch complete
#   odd  = mode switch in progress / device disabled
# GZDoom's GL init must observe an EVEN value; odd makes __glNVStateInit
# clear initBits, buffer objects get no GPU memory, and GZDoom crashes at
# 0x20 in FFlatVertexBuffer::Copy.
#
# STDOUT contract (parse this, nothing else):
#   MODENUM <n>
#   NO_XORG
#
# Exit codes:
#   0  MODENUM printed
#   3  NO_XORG (no Xorg process with an nvClip fd)
#   4  permission / unexpected error (NO_XORG still printed)
#
# Usage:
#   sudo ./guest_mode.sh                 # one shot, display from $DISPLAY (default :0)
#   sudo ./guest_mode.sh --wait 15       # poll up to 15 s for Xorg's nvClip fd
#   sudo ./guest_mode.sh --pid 1515      # force one Xorg pid (testing/forensics)
#   sudo ./guest_mode.sh --interval 50   # poll interval ms (with --wait)
#   sudo ./guest_mode.sh --debug         # candidate details on stderr
#
# Requires: bash, python3 (stdlib only), root (or a user that can read
# /proc/<Xorg>/fd; Xorg normally runs as root in the guest).

set -u

if ! command -v python3 >/dev/null 2>&1; then
    echo "guest_mode.sh: python3 not found" >&2
    echo "NO_XORG"
    exit 4
fi

exec python3 - "$@" <<'PY'
import argparse
import os
import re
import struct
import sys
import time

NVCLIP_TARGET_RE = re.compile(r'(?:^|/)\.nvidia_drv\.[^/]*$|memfd:.*nvidia_drv')
# 570.144 initial size is 0x245000.  ExtendSharedArea() ftruncates upward
# when a second channel block is needed, so accept a range, prefer exact.
NVCLIP_SIZE_EXACT = 0x245000
NVCLIP_SIZE_MIN = 0x80000
NVCLIP_SIZE_MAX = 0x800000
MODE_OFF = 0x8
TOKEN_OFF = 0x4


def eprint(msg):
    print(msg, file=sys.stderr)


def parse_args():
    p = argparse.ArgumentParser(add_help=True)
    p.add_argument('--pid', type=int, default=0,
                   help='only inspect this pid (bypasses the Xorg comm check)')
    p.add_argument('--display', default=os.environ.get('DISPLAY', ':0'),
                   help='X display to prefer (default $DISPLAY or :0)')
    p.add_argument('--wait', type=float, default=0.0,
                   help='poll up to SECONDS for the fd (default 0 = one shot)')
    p.add_argument('--interval', type=float, default=200.0,
                   help='poll interval in milliseconds (default 200)')
    p.add_argument('--debug', action='store_true',
                   help='print candidate details to stderr')
    return p.parse_args()


def read_text(path):
    with open(path, 'rb') as f:
        return f.read()


def proc_args(pid):
    try:
        raw = read_text('/proc/%d/cmdline' % pid).split(b'\0')
    except OSError:
        return []
    return [x.decode('utf-8', 'replace') for x in raw if x]


def proc_comm(pid):
    try:
        return read_text('/proc/%d/comm' % pid).decode('utf-8', 'replace').strip()
    except OSError:
        return ''


def proc_starttime(pid):
    # /proc/pid/stat field 22 (1-based).  Fields after the comm field:
    # rest[0] is field 3 (state), so starttime is rest[19].
    try:
        data = read_text('/proc/%d/stat' % pid).decode('utf-8', 'replace')
        rest = data.rsplit(')', 1)[1].split()
        return int(rest[19])
    except (OSError, IndexError, ValueError):
        return 0


def display_number(display):
    # ":0", ":0.0", "host:0.0" -> 0
    try:
        tail = display.rsplit(':', 1)[1]
        return int(tail.split('.', 1)[0])
    except (IndexError, ValueError):
        return None


def is_xorg(pid):
    comm = proc_comm(pid)
    if comm in ('Xorg', 'X'):
        return True
    args = proc_args(pid)
    if not args:
        return False
    base = os.path.basename(args[0])
    return base in ('Xorg', 'X')


def display_matches(pid, disp):
    if disp is None:
        return False
    needle = ':%d' % disp
    for arg in proc_args(pid):
        if arg == needle or arg.startswith(needle + '.'):
            return True
    return False


def candidate_fds(pid):
    """Yield (fd_name, target, size) for nvClip-like fds of pid."""
    fd_dir = '/proc/%d/fd' % pid
    try:
        names = os.listdir(fd_dir)
    except PermissionError:
        raise
    except OSError:
        return
    for name in names:
        if not name.isdigit():
            continue
        path = os.path.join(fd_dir, name)
        try:
            target = os.readlink(path)
            st = os.stat(path)
        except PermissionError:
            raise
        except OSError:
            continue
        if not NVCLIP_TARGET_RE.search(target):
            continue
        if not (NVCLIP_SIZE_MIN <= st.st_size <= NVCLIP_SIZE_MAX):
            continue
        yield name, target, st.st_size


def read_candidate(pid, fd_name, target, size):
    path = '/proc/%d/fd/%s' % (pid, fd_name)
    try:
        fd = os.open(path, os.O_RDONLY)
    except OSError as exc:
        return None, str(exc)
    try:
        blob = os.pread(fd, 12, 0)
    except OSError as exc:
        return None, str(exc)
    finally:
        os.close(fd)
    if len(blob) < 12:
        return None, 'short read (%d bytes)' % len(blob)
    grabbed, token, mode = struct.unpack_from('<III', blob, 0)
    if mode > 0xFFFF:
        return None, 'implausible modeNum 0x%x' % mode
    return {
        'pid': pid,
        'fd': int(fd_name),
        'target': target,
        'size': size,
        'grabbed': grabbed,
        'token': token,
        'mode': mode,
        'start': proc_starttime(pid),
        'disp': display_matches(pid, display_number(args.display)),
    }, None


def scan(debug=False):
    """Return (best_candidate_or_None, saw_permission_error)."""
    best = None
    perm_error = False

    try:
        pids = [int(x) for x in os.listdir('/proc') if x.isdigit()]
    except OSError as exc:
        eprint('guest_mode.sh: cannot list /proc: %s' % exc)
        return None, False

    for pid in pids:
        if args.pid and pid != args.pid:
            continue
        if not args.pid and not is_xorg(pid):
            continue
        try:
            fds = list(candidate_fds(pid))
        except PermissionError:
            perm_error = True
            if debug:
                eprint('pid %d: permission denied listing fd/' % pid)
            continue
        for fd_name, target, size in fds:
            cand, err = read_candidate(pid, fd_name, target, size)
            if cand is None:
                if debug:
                    eprint('pid %d fd %s: %s' % (pid, fd_name, err))
                continue
            if debug:
                eprint('candidate pid=%d fd=%s size=0x%x token=0x%x mode=%d '
                       'start=%d display_match=%s target=%s' %
                       (cand['pid'], cand['fd'], cand['size'], cand['token'],
                        cand['mode'], cand['start'], cand['disp'], cand['target']))
            # Score: display match, exact size, nonzero token, newest Xorg.
            key = (cand['disp'],
                   1 if cand['size'] == NVCLIP_SIZE_EXACT else 0,
                   1 if cand['token'] != 0 else 0,
                   cand['start'],
                   -cand['pid'])
            if best is None or key > best[0]:
                best = (key, cand)

    return (best[1] if best else None), perm_error


def main():
    global args
    args = parse_args()
    deadline = time.monotonic() + max(0.0, args.wait)
    interval = max(0.01, args.interval / 1000.0)
    perm_error = False

    while True:
        cand, pe = scan(args.debug)
        perm_error = perm_error or pe
        if cand is not None:
            print('MODENUM %d' % cand['mode'])
            return 0
        if time.monotonic() >= deadline:
            break
        time.sleep(interval)

    if perm_error:
        eprint('guest_mode.sh: Xorg exists but /proc/<pid>/fd is not readable; '
               'run as root (sudo).')
        print('NO_XORG')
        return 4
    print('NO_XORG')
    return 3


if __name__ == '__main__':
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print('NO_XORG')
        sys.exit(4)
    except Exception as exc:  # keep the stdout contract even on bugs
        eprint('guest_mode.sh: unexpected error: %s' % exc)
        print('NO_XORG')
        sys.exit(4)
PY