commit dbca2b60ce279520826d4c578fcab75e60452541
parent 9b367ba45596268931c1e3728400053b8fe333ee
Author: Jared Tobin <jared@jtobin.io>
Date: Wed, 21 Oct 2026 08:56:49 +0400
plugin: a guest that starts a second thread is stopped, its dump refused
The plugin's state is unlocked and its successor fingerprints follow
one stream of instructions, so it records one guest thread. A guest
that started another (QEMU's user mode gives each thread a vCPU) was
recorded anyway, by threads racing on that state: a dump that could
be wrong anywhere, or a crash.
The plugin now counts vCPUs as they start. At the second, it writes a
dump holding only `T 2`, says why on stderr, and exits; readDump
refuses such a dump. The probe check runs a guest that starts a
thread and holds record and diff to failing on it.
Diffstat:
6 files changed, 69 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
@@ -137,8 +137,9 @@ ppad-poly1305, and ppad-secp256k1.
* valgrind and QEMU's user mode run on Linux; elsewhere vestigia only
reads their reports. QEMU traces other ISAs differentially, but
memcheck needs a native host.
-* The plugin assumes a single-threaded guest, and runs compared by
- `diff` must differ only in the secret, argument lengths included.
+* The plugin records one guest thread: a guest that starts a second
+ is stopped, and its dump refused. Runs compared by `diff` must
+ differ only in the secret, argument lengths included.
* A GHC harness wants `-with-rtsopts=-xr1G -V0`, against the
runtime's terabyte reservation and its interval timer.
* Source lines need line tables.
diff --git a/etc/probe/threads.c b/etc/probe/threads.c
@@ -0,0 +1,16 @@
+/*
+ * threads.c: a guest that starts a second thread, which the plugin
+ * stops rather than record.
+ *
+ * Copyright (c) 2026 Jared Tobin
+ * License: MIT
+ */
+#include <pthread.h>
+
+static void *work(void *p) { return p; }
+
+int main(void) {
+ pthread_t t;
+ if (pthread_create(&t, NULL, work, NULL) != 0) return 1;
+ return pthread_join(t, NULL);
+}
diff --git a/lib/Vestigia/Diff.hs b/lib/Vestigia/Diff.hs
@@ -103,7 +103,9 @@ data Dump = Dump {
-- | Read a plugin dump. A dump in which no instruction ran records
-- no run (the plugin writes every instruction the guest executed),
--- and is refused rather than compared as one that did nothing.
+-- and is refused rather than compared as one that did nothing, as
+-- is the dump of a guest the plugin stopped for starting a second
+-- thread.
readDump :: T.Text -> Either String Dump
readDump t = go (T.lines t) M.empty M.empty
where
@@ -124,6 +126,9 @@ readDump t = go (T.lines t) M.empty M.empty
k <- unhex to
c <- unhex n
go rest is (M.insertWith M.union f (M.singleton k c) es)
+ [ "T", _ ] ->
+ Left "the guest started a second thread, which a trace \
+ \cannot record: the run was stopped"
[] -> go rest is es
_ -> Left ("an unreadable dump line: " <> T.unpack l)
unhex x = case readHex (T.unpack x) of
diff --git a/nix/probe.nix b/nix/probe.nix
@@ -19,6 +19,11 @@ let
readelf -h $out/bin/probe | grep -q 'Type: *EXEC'
'';
+ threads = pkgs.runCommandCC "vestigia-probe-threads" { } ''
+ mkdir -p $out/bin
+ cc -O0 -pthread -o $out/bin/threads ${../etc/probe/threads.c}
+ '';
+
secrets = [ "00000005" "0000003a" "000000c3" ];
objects = [ "/bin/probe" ];
@@ -58,5 +63,18 @@ pkgs.runCommand "vestigia-probe-check" {
check ${diff}/diff.json \
'[.origins[] | [.class, .frames[0].function]] | unique' \
'${builtins.toJSON expected.diff}'
+
+ # a guest that starts a second thread is stopped, and its dump
+ # refused
+ v=${vestigia.vestigia}/bin/vestigia
+ if $v record --dump threads -- ${threads}/bin/threads; then
+ echo "probe: a guest's second thread was recorded" >&2
+ exit 1
+ fi
+ if $v diff threads threads 2> refusal; then
+ echo "probe: a stopped guest's dump was compared" >&2
+ exit 1
+ fi
+ grep -q 'second thread' refusal
touch $out
''
diff --git a/plugin/vestigia-qemu.c b/plugin/vestigia-qemu.c
@@ -23,6 +23,8 @@
* I <pc> <count> <accesses> <fingerprint> <order> <object> <address>
* E <from> <to> <count>
*
+ * or, for a guest that started a second thread, the line `T 2` alone.
+ *
* in hex, where <object> is the file the instruction's bytes were
* mapped from ("-" if none, or if the file's program headers cannot
* be read) and <address> its address in that file: where the file's
@@ -30,7 +32,9 @@
* it. In a shared library that is often the instruction's offset in
* the file; in an executable linked at a fixed address it is not.
*
- * A single-threaded guest is assumed: the state below is unlocked.
+ * The state below is unlocked, so one guest thread is recorded: a
+ * guest that starts a second one is stopped there, its dump saying
+ * so, rather than recorded by threads racing on it.
*/
#include <elf.h>
#include <glib.h>
@@ -38,6 +42,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <unistd.h>
#include <qemu-plugin.h>
@@ -57,6 +62,7 @@ typedef struct Insn {
static GHashTable *insns; /* pc -> Insn */
static Insn *prev; /* the instruction executed last */
static char *out_path;
+static gint vcpus; /* the guest's threads, a vCPU each */
static const uint64_t fnv_offset = 0xcbf29ce484222325ULL;
static const uint64_t fnv_prime = 0x100000001b3ULL;
@@ -108,6 +114,21 @@ static void on_mem(unsigned int vcpu, qemu_plugin_meminfo_t info,
i->accesses++;
}
+static void on_vcpu_init(qemu_plugin_id_t id, unsigned int vcpu) {
+ (void)id;
+ (void)vcpu;
+ if (g_atomic_int_add(&vcpus, 1) > 0) {
+ FILE *out = fopen(out_path, "w");
+ if (out != NULL) {
+ fprintf(out, "T 2\n");
+ fclose(out);
+ }
+ fprintf(stderr, "vestigia-qemu: the guest started a second thread, "
+ "which a trace cannot record; the run is stopped\n");
+ _exit(EXIT_FAILURE);
+ }
+}
+
static void on_translate(qemu_plugin_id_t id, struct qemu_plugin_tb *tb) {
size_t n = qemu_plugin_tb_n_insns(tb);
(void)id;
@@ -277,6 +298,7 @@ QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id,
return -1;
}
insns = g_hash_table_new(g_int64_hash, g_int64_equal);
+ qemu_plugin_register_vcpu_init_cb(id, on_vcpu_init);
qemu_plugin_register_vcpu_tb_trans_cb(id, on_translate);
qemu_plugin_register_atexit_cb(id, at_exit, NULL);
return 0;
diff --git a/test/Main.hs b/test/Main.hs
@@ -270,6 +270,9 @@ diffTests = testGroup "diff"
, testCase "an unreadable line is refused" $
isLeft (readDump "I 10 zz 0 f 0 /lib.so 0\n") @? "accepted"
+
+ , testCase "a guest stopped for a second thread is refused" $
+ isLeft (readDump "T 2\n") @? "accepted"
]
where
-- 0x10 is a branch run four times, taken twice in A and three