#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-watchdogdev. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds, so taking one start for every measurement is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE IS. `watchdogdev' is 557 lines of C and nothing else: one extension type, `watchdogdev.watchdog', wrapping open(2)/write(2)/close(2) and the eleven WDIOC_* ioctls of , plus the WDIOF_*/WDIOS_* flag constants. There is no Python source in the distribution, no module-level function, and no pure-Python fallback -- so `import watchdogdev' succeeding IS the load of a wasm side module against the interpreter's exported C API, and is the single most load-bearing line in this file. NOTHING HERE ARMS A WATCHDOG, AND THAT IS A DESIGN CONSTRAINT RATHER THAN A CONSEQUENCE OF THE IMAGE. A watchdog is the one device class where a SUCCESSFUL open has consequences: the kernel starts a timer and reboots the machine if nothing pings it. So the probe never opens a watchdog node. It checks /dev/watchdog* first and, if any node exists, it declines the open outright and says so -- `dev_nodes' carries the list and the open cases report `skipped-device-present' rather than a result. On an image with no node (this one: wasm32_defconfig sets no CONFIG_WATCHDOG, so the subsystem is not built and devtmpfs has nothing to publish) the open is attempted precisely because it is known to fail, and what is measured is that it fails in the documented way. THE FD THAT STANDS IN FOR A DEVICE IS A REGULAR FILE. watchdog.__new__ opens whatever path it is handed -- it does not check that the fd is a watchdog -- so every method can be exercised against an ordinary file under $TMPDIR. That is the whole of the behavioural half: the ioctls come back ENOTTY from the kernel's generic path, write() writes bytes we then read back, and magic_close() writes its `V' to a file instead of to a timer. No device is involved at any point, and nothing in this probe can reboot anything. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- every value below is a single line, and bytes are emitted as hex. And no value may depend on the encoding stdout happens to be in: every one is ASCII. """ import errno import os import struct import sys import time emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def call(fn, *a, **kw): """Return fn(*a) as a string, or `!ExceptionName' if it raised. Asserted by exception NAME rather than by message: the message is CPython's and has moved between releases, while the name is the contract a caller has to code against.""" try: return fn(*a, **kw) except Exception as exc: return '!' + type(exc).__name__ def errno_of(fn, *a, **kw): """`/' for a call that must raise an OSError.""" try: fn(*a, **kw) except OSError as exc: return '%s/%s' % (type(exc).__name__, errno.errorcode.get(exc.errno, str(exc.errno))) except Exception as exc: return '%s/not-an-oserror' % type(exc).__name__ return 'no-exception' try: import watchdogdev except Exception as exc: print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # ============================================================== identity ===== emit('module_file', getattr(watchdogdev, '__file__', '')) emit('is_package', 'yes' if hasattr(watchdogdev, '__path__') else 'no') emit('is_extension', 'yes' if getattr(watchdogdev, '__file__', '').endswith('.so') else 'no') # THE MODULE CARRIES NO __version__. Nothing in watchdogdev.c adds one, so the # only version this package states anywhere is the one in its .dist-info, which # the suite reads off the rootfs. A caller cannot ask the module what it is. emit('has_version_attr', 'yes' if hasattr(watchdogdev, '__version__') else 'no') emit('has_dunder_all', 'yes' if hasattr(watchdogdev, '__all__') else 'no') emit('doc_first_line', (watchdogdev.__doc__ or '').split('\n')[0]) # EVERY PUBLIC NAME, in one sorted list. With no __all__ this is also exactly # what `from watchdogdev import *' leaks, which is what the README tells a user # to write. public = sorted(n for n in dir(watchdogdev) if not n.startswith('_')) emit('public_names', ','.join(public)) emit('public_count', len(public)) # NO MODULE-LEVEL FUNCTION EXISTS. watchdogdev.c's module_functions[] is the # sentinel alone, so the type is the entire API surface. emit('callable_names', ','.join(n for n in public if callable(getattr(watchdogdev, n)))) # ================================================================== type ===== wd = watchdogdev.watchdog emit('type_is_type', 'yes' if isinstance(wd, type) else 'no') emit('type_name', wd.__name__) emit('type_qualname', getattr(wd, '__qualname__', '')) emit('type_module', wd.__module__) emit('type_base', wd.__bases__[0].__name__) # NOT SUBCLASSABLE: tp_flags is Py_TPFLAGS_DEFAULT with no Py_TPFLAGS_BASETYPE, # so a caller who wants to wrap this object has to compose rather than inherit. def _subclass(): class _W(wd): pass return _W _sub = call(_subclass) emit('subclassable', _sub if isinstance(_sub, str) else 'yes') # THE METHOD AND PROPERTY ROSTERS, sorted. These are the "documented functions # present" the ticket asks for, read off the built object rather than off # README.txt. methods = sorted(n for n in dir(wd) if not n.startswith('_') and callable(getattr(wd, n, None))) props = sorted(n for n in dir(wd) if not n.startswith('_') and not callable(getattr(wd, n, None))) emit('methods', ','.join(methods)) emit('method_count', len(methods)) emit('properties', ','.join(props)) # EVERY METHOD IS DOCUMENTED, except the three file methods upstream left bare. emit('undocumented_methods', ','.join(n for n in methods if not (getattr(wd, n).__doc__ or '').strip())) # ============================================================= constants ===== # THE IOCTL REQUEST NUMBERS. Each is compared against the value _IOR/_IOWR # computes from , MASKED TO 32 BITS -- see the suite's header # for why the raw values are negative here and positive on a 64-bit host, and # why the mask is the assertion that means the same thing on both. IOCTLS = [ ('WDIOC_GETSUPPORT', 2, 0, 40), ('WDIOC_GETSTATUS', 2, 1, 4), ('WDIOC_GETBOOTSTATUS', 2, 2, 4), ('WDIOC_GETTEMP', 2, 3, 4), ('WDIOC_SETOPTIONS', 2, 4, 4), ('WDIOC_KEEPALIVE', 2, 5, 4), ('WDIOC_SETTIMEOUT', 3, 6, 4), ('WDIOC_GETTIMEOUT', 2, 7, 4), ('WDIOC_SETPRETIMEOUT', 3, 8, 4), ('WDIOC_GETPRETIMEOUT', 2, 9, 4), ('WDIOC_GETTIMELEFT', 2, 10, 4), ] for name, _d, _nr, _sz in IOCTLS: raw = getattr(watchdogdev, name, None) if raw is None: emit('ioc_%s' % name.lower(), 'absent') emit('ioc_%s_raw' % name.lower(), 'absent') continue emit('ioc_%s' % name.lower(), '0x%08x' % (raw & 0xFFFFFFFF)) emit('ioc_%s_raw' % name.lower(), raw) # THE DECODED FIELDS OF EACH REQUEST, which is where a width mismatch would # actually show. `_IOC' packs dir<<30 | size<<16 | type<<8 | nr, so the size # field IS sizeof(struct watchdog_info) for GETSUPPORT and sizeof(int) for the # other ten -- read out of the compiled module rather than out of a header. _dirs = [] _sizes = [] _types = [] _nrs = [] for name, d, nr, sz in IOCTLS: raw = getattr(watchdogdev, name, None) if raw is None: continue v = raw & 0xFFFFFFFF _dirs.append('%s:%d' % (name, (v >> 30) & 0x3)) _sizes.append('%s:%d' % (name, (v >> 16) & 0x3FFF)) _types.append((v >> 8) & 0xFF) _nrs.append('%s:%d' % (name, v & 0xFF)) emit('ioc_dirs', ','.join(_dirs)) emit('ioc_sizes', ','.join(_sizes)) emit('ioc_nrs', ','.join(_nrs)) emit('ioc_types_all_W', 'yes' if _types and set(_types) == {ord('W')} else 'no') # THE STRUCTURE. Its size is not a constant the module exports -- it is carried # in GETSUPPORT's size field, which is the compiler's own sizeof(struct # watchdog_info) on this arm. `struct watchdog_info' is __u32 options, __u32 # firmware_version, __u8 identity[32]; so 40 is the answer with 4-byte ints and # no tail padding, and Python's struct module is asked the same question # independently. _gs = getattr(watchdogdev, 'WDIOC_GETSUPPORT', 0) & 0xFFFFFFFF emit('watchdog_info_size', (_gs >> 16) & 0x3FFF) emit('watchdog_info_calcsize', struct.calcsize('=II32s')) emit('watchdog_info_offsets', ','.join(str(o) for o in (0, struct.calcsize('=I'), struct.calcsize('=II')))) emit('int_size_from_ioctl', (getattr(watchdogdev, 'WDIOC_GETSTATUS', 0) & 0xFFFFFFFF) >> 16 & 0x3FFF) # THE FLAG CONSTANTS, verbatim from . FLAGS = ['WDIOF_UNKNOWN', 'WDIOS_UNKNOWN', 'WDIOF_OVERHEAT', 'WDIOF_FANFAULT', 'WDIOF_EXTERN1', 'WDIOF_EXTERN2', 'WDIOF_POWERUNDER', 'WDIOF_CARDRESET', 'WDIOF_POWEROVER', 'WDIOF_SETTIMEOUT', 'WDIOF_MAGICCLOSE', 'WDIOF_PRETIMEOUT', 'WDIOF_KEEPALIVEPING', 'WDIOS_DISABLECARD', 'WDIOS_ENABLECARD', 'WDIOS_TEMPPANIC'] for name in FLAGS: emit('flag_%s' % name.lower(), getattr(watchdogdev, name, 'absent')) # WDIOF_ALARMONLY IS IN THE HEADER AND NOT IN THE MODULE. The kernel added # 0x0400 for a watchdog that raises an alarm instead of resetting; watchdogdev # 1.0.0 predates it and exports the other twelve. A caller reading `options' # cannot name that bit through this module. emit('has_wdiof_alarmonly', 'yes' if hasattr(watchdogdev, 'WDIOF_ALARMONLY') else 'no') # THE FLAGS ARE DISJOINT SINGLE BITS, other than the two -1 sentinels -- the # property a caller relies on every time they write `options & WDIOF_x'. _bits = [getattr(watchdogdev, n) for n in FLAGS if n.startswith('WDIOF_') and n != 'WDIOF_UNKNOWN' and hasattr(watchdogdev, n)] emit('wdiof_all_single_bit', 'yes' if _bits and all(b > 0 and b & (b - 1) == 0 for b in _bits) else 'no') _or = 0 _overlap = 'no' for b in _bits: if _or & b: _overlap = 'yes' _or |= b emit('wdiof_overlap', _overlap) emit('wdiof_union', '0x%04x' % _or) # THE INTERPRETER'S WIDTHS, which are what make the raw constants negative. # PyModule_AddIntConstant takes a `long', the _IOC expression is an unsigned # int, and on this ILP32 arm a long is 32 bits -- so every request number with # bit 31 set arrives as a negative Python int. Emitted so the suite can state # the relationship rather than a literal that is only true on one arm. emit('maxsize', sys.maxsize) emit('long_bits', 32 if sys.maxsize == 2 ** 31 - 1 else 64) _neg = [n for n, _d, _nr, _s in IOCTLS if isinstance(getattr(watchdogdev, n, None), int) and getattr(watchdogdev, n) < 0] emit('negative_ioctls', ','.join(_neg)) emit('negative_ioctl_count', len(_neg)) # ...and the consistency claim that holds on EITHER width: every request number # is congruent mod 2**32 to what the header computes. _agree = all((getattr(watchdogdev, n, None) & 0xFFFFFFFF) == (((d << 30) | (sz << 16) | (ord('W') << 8) | nr) & 0xFFFFFFFF) for n, d, nr, sz in IOCTLS if hasattr(watchdogdev, n)) emit('ioctls_agree_with_header', 'yes' if _agree else 'no') # ========================================================= the device ======== # WHAT IS ACTUALLY ON /dev. Measured before anything is opened, and it is what # decides whether the open cases below run at all. _nodes = [] try: for n in sorted(os.listdir('/dev')): if n.startswith('watchdog'): _nodes.append(n) except Exception: pass emit('dev_nodes', ','.join(_nodes) if _nodes else 'none') emit('dev_watchdog_exists', 'yes' if os.path.exists('/dev/watchdog') else 'no') # THE OPEN THAT MUST FAIL, and the reason it is safe to attempt. With no node # present there is no timer to start, so this measures the documented refusal # and nothing else. With a node present it is NOT attempted: opening a real # watchdog arms it. if _nodes or os.path.exists('/dev/watchdog'): emit('open_nodev', 'skipped-device-present') emit('open_nodev_errno', 'skipped-device-present') emit('open_nodev_filename', 'skipped-device-present') emit('open_nodev_ms', 'skipped-device-present') emit('open_nodev_is_oserror', 'skipped-device-present') else: _t0 = time.time() emit('open_nodev', call(watchdogdev.watchdog, '/dev/watchdog')) emit('open_nodev_ms', int((time.time() - _t0) * 1000)) emit('open_nodev_errno', errno_of(watchdogdev.watchdog, '/dev/watchdog')) try: watchdogdev.watchdog('/dev/watchdog') emit('open_nodev_filename', 'no-exception') emit('open_nodev_is_oserror', 'no-exception') except OSError as exc: emit('open_nodev_filename', exc.filename) emit('open_nodev_is_oserror', 'yes') except Exception: emit('open_nodev_filename', 'not-an-oserror') emit('open_nodev_is_oserror', 'no') # ================================================== the constructor ========== W = os.path.join(os.environ.get('TMPDIR', '/tmp'), 'bst-watchdogdev-probe.%d' % os.getpid()) os.mkdir(W) _saved_cwd = os.getcwd() def p(name): return os.path.join(W, name) # ARGUMENT PARSING, which is "s:__new__" and therefore accepts one str and # nothing else. A caller who passes bytes -- the natural spelling for a device # path -- gets a TypeError before anything is opened. emit('ctor_no_args', call(watchdogdev.watchdog)) emit('ctor_two_args', call(watchdogdev.watchdog, p('a'), p('b'))) emit('ctor_int', call(watchdogdev.watchdog, 1)) emit('ctor_none', call(watchdogdev.watchdog, None)) emit('ctor_bytes', call(watchdogdev.watchdog, b'/dev/watchdog')) emit('ctor_embedded_nul', call(watchdogdev.watchdog, '/dev/wat\0chdog')) # THE CONSTRUCTOR TAKES NO KEYWORD. tp_new parses `args' and ignores `kwds' # entirely, so a keyword is not an unexpected-keyword error but an empty-tuple # one. emit('ctor_keyword', call(watchdogdev.watchdog, **{'filename': p('a')})) # THE FAILURE MODES OF open(2) ITSELF, each reached through the constructor and # each reported with the errno the kernel gave. O_WRONLY is what the module # passes, which is why a directory is EISDIR here rather than opening fine. emit('ctor_missing', errno_of(watchdogdev.watchdog, p('does-not-exist'))) emit('ctor_directory', errno_of(watchdogdev.watchdog, W)) _ro = p('readonly') open(_ro, 'w').close() os.chmod(_ro, 0o444) emit('ctor_readonly', errno_of(watchdogdev.watchdog, _ro) if os.geteuid() != 0 else 'skipped-running-as-root') emit('euid_is_root', 'yes' if os.geteuid() == 0 else 'no') # ================================================ a regular file as the fd === # EVERY BEHAVIOURAL CASE BELOW RUNS AGAINST THIS FILE. __new__ opens whatever # path it is handed and never checks that the result is a watchdog, so the file # gives a real, fully constructed object with no device anywhere near it. TARGET = p('fake-watchdog') open(TARGET, 'w').close() dev = watchdogdev.watchdog(TARGET) emit('obj_type', type(dev).__name__) emit('obj_name', dev.name) emit('obj_name_is_argument', 'yes' if dev.name == TARGET else 'no') emit('obj_closed_initial', dev.closed) _fd = dev.fileno() emit('obj_fileno_type', type(_fd).__name__) emit('obj_fileno_nonnegative', 'yes' if isinstance(_fd, int) and _fd >= 0 else 'no') # THE THREE GETSUPPORT ATTRIBUTES ARE None UNTIL get_support() HAS RUN, because # they read a struct the object has not allocated yet. A caller who reads # `wdt.options' without calling get_support() first gets None rather than an # error -- and `options & WDIOF_MAGICCLOSE' on None is a TypeError one step # further on, which is the shape of the bug that produces. emit('options_before', dev.options) emit('firmware_version_before', dev.firmware_version) emit('identity_before', dev.identity) emit('options_before_is_none', 'yes' if dev.options is None else 'no') # THE ELEVEN IOCTL WRAPPERS AGAINST A NON-WATCHDOG FD. The kernel's generic # path refuses an ioctl a file's driver does not implement, and the module turns # that into an OSError carrying the errno -- which is the documented behaviour # the ticket asks for, reached without a device. IOCTL_CALLS = [ ('get_support', ()), ('keep_alive', ()), ('get_status', ()), ('get_boot_status', ()), ('get_temp', ()), ('get_timeout', ()), ('get_pretimeout', ()), ('get_time_left', ()), ('set_options', (0,)), ('set_timeout', (30,)), ('set_pretimeout', (10,)), ] _results = [] for meth, argv in IOCTL_CALLS: _results.append(errno_of(getattr(dev, meth), *argv)) emit('ioctl_%s' % meth, _results[-1]) # ...and the two summary facts the suite asserts instead of eleven literals: the # eleven agree with each other, and what they agree ON. emit('ioctl_all_agree', 'yes' if len(set(_results)) == 1 else 'no') emit('ioctl_verdict', _results[0] if _results else 'none') emit('ioctl_distinct', ','.join(sorted(set(_results)))) # A FAILED get_support() STILL LEAVES THE BUFFER BEHIND, and that is an upstream # defect rather than a curiosity. get_support() mallocs `struct watchdog_info' # BEFORE the ioctl and frees nothing when the ioctl fails, so the three getters # stop answering None and start answering whatever was in that allocation. A # caller who follows README.txt -- get_support(), then `options & # WDIOF_MAGICCLOSE' -- and does not check for the exception therefore reads # uninitialised memory as a flags word, which is strictly worse than the None # they would have got had they never called it. # # WHAT IS ASSERTED IS THE TRANSITION, NOT THE CONTENTS. The bytes are # uninitialised, so their VALUE is not a fact about anything and no case may # expect one; `getsupport_leaves_buffer' states only that None is no longer what # comes back, which is deterministic because the malloc always precedes the # ioctl. The reads are guarded because `identity' runs PyUnicode_FromString over # those same bytes and can raise on invalid UTF-8 -- and its value is never # printed, which would put arbitrary bytes into the line protocol. emit('getsupport_leaves_buffer', 'yes' if call(lambda: dev.options) is not None else 'no') emit('options_after_failed_getsupport_type', type(call(lambda: dev.options)).__name__) emit('identity_after_failed_getsupport_type', type(call(lambda: dev.identity)).__name__) # ARGUMENT CHECKING ON THE SETTERS, which is "i" and therefore rejects a str and # accepts a bool. Nothing here reaches the ioctl on a value it should not. emit('set_timeout_str', call(dev.set_timeout, 'thirty')) emit('set_timeout_float', call(dev.set_timeout, 1.5)) emit('set_timeout_noargs', call(dev.set_timeout)) emit('set_options_str', call(dev.set_options, 'off')) emit('keep_alive_with_arg', call(dev.keep_alive, 1)) # ============================================================= write() ======= # THE PACKAGE'S PRIMARY METHOD, and the one the recipe carries a patch for. On # an unpatched build every case in this block is a SystemError -- "s#" without # PY_SSIZE_T_CLEAN -- so `write_bytes' is where a lost patch shows up first. emit('write_bytes', call(dev.write, b'ping')) emit('write_str', call(dev.write, 'V')) emit('write_empty', call(dev.write, b'')) emit('write_bytearray', call(dev.write, bytearray(b'ab'))) emit('write_memoryview', call(dev.write, memoryview(b'xyz'))) emit('write_int', call(dev.write, 1)) emit('write_none', call(dev.write, None)) emit('write_noargs', call(dev.write)) emit('write_return_type', type(call(dev.write, b'z')).__name__) # WHAT ACTUALLY REACHED THE FILE. Read back through a second descriptor, so this # is the bytes the kernel has rather than the count the method returned. dev.close() with open(TARGET, 'rb') as fh: emit('write_file_bytes', fh.read().hex()) # ================================================ close / magic_close ======== # close() IS IDEMPOTENT and leaves fileno() answering -1 rather than raising -- # so a caller cannot distinguish a closed object from a live one by asking for # its descriptor, only by reading `closed'. emit('closed_after_close', dev.closed) emit('fileno_after_close', dev.fileno()) emit('close_again', call(dev.close)) emit('magic_close_after_close', call(dev.magic_close)) emit('write_after_close', errno_of(dev.write, b'x')) emit('ioctl_after_close', errno_of(dev.get_status)) # `name' SURVIVES close(), because it is a copy of the constructor's argument # and not a property of the descriptor. Compared rather than printed: the path # carries this probe's pid. emit('name_survives_close', 'yes' if dev.name == TARGET else 'no') # magic_close() IS THE MAGIC-CLOSE SEQUENCE, and on a real device it is what # stops the timer instead of letting it expire: write `V', then close. Against a # regular file it is the same two syscalls with nothing behind them, which is # what makes it measurable here. It uses write(2) DIRECTLY rather than the # method above, so it is also the control that says the write() defect was in # the argument parser and not in the descriptor. MAGIC = p('magic-target') open(MAGIC, 'w').close() m = watchdogdev.watchdog(MAGIC) emit('magic_close_result', call(m.magic_close)) emit('magic_close_closed', m.closed) emit('magic_close_fileno', m.fileno()) with open(MAGIC, 'rb') as fh: emit('magic_close_wrote', fh.read().hex()) # THE OBJECT IS NOT A CONTEXT MANAGER and is not iterable, both of which a # caller who took "can be simply used as file object" literally would try. emit('has_enter', 'yes' if hasattr(m, '__enter__') else 'no') emit('has_iter', 'yes' if hasattr(m, '__iter__') else 'no') emit('has_read', 'yes' if hasattr(m, 'read') else 'no') emit('has_flush', 'yes' if hasattr(m, 'flush') else 'no') # DEALLOC CLOSES THE DESCRIPTOR. The type has no tp_dealloc that leaks: dropping # the last reference to a live object closes its fd, which is what keeps a # program that forgets to close from holding a watchdog open. open(p('leak-target'), 'w').close() _l = watchdogdev.watchdog(p('leak-target')) _lfd = _l.fileno() del _l try: os.fstat(_lfd) emit('dealloc_closed_fd', 'no') except OSError as exc: emit('dealloc_closed_fd', 'yes' if exc.errno == errno.EBADF else errno.errorcode.get(exc.errno, str(exc.errno))) # ================================================================= cleanup === os.chdir(_saved_cwd) for _f in sorted(os.listdir(W)): try: os.chmod(os.path.join(W, _f), 0o600) os.unlink(os.path.join(W, _f)) except OSError: pass try: os.rmdir(W) except OSError: pass emit('workdir_gone', 'yes' if not os.path.exists(W) else 'no') emit('emitted_count', len(emitted)) print('probe_done=yes')