BPF List
 help / color / mirror / Atom feed
From: Puranjay Mohan <puranjay@kernel.org>
To: bpf@vger.kernel.org
Cc: Puranjay Mohan <puranjay@kernel.org>,
	"Alexei Starovoitov" <ast@kernel.org>,
	"Daniel Borkmann" <daniel@iogearbox.net>,
	"Andrii Nakryiko" <andrii@kernel.org>,
	"Martin KaFai Lau" <martin.lau@linux.dev>,
	"Eduard Zingerman" <eddyz87@gmail.com>,
	"Kumar Kartikeya Dwivedi" <memxor@gmail.com>,
	"Song Liu" <song@kernel.org>,
	"Yonghong Song" <yonghong.song@linux.dev>
Subject: [PATCH bpf-next 2/2] selftests/bpf: Add bpf_call_rcu tests for hash and LRU hash maps
Date: Thu, 24 Sep 2026 09:28:57 -0700	[thread overview]
Message-ID: <20260924162858.2435106-3-puranjay@kernel.org> (raw)
In-Reply-To: <20260924162858.2435106-1-puranjay@kernel.org>

Each of these checks the element a callback was armed on is still the one
it runs on, by the value it sees.

hash_delete arms, deletes, then inserts another key, which would be handed
the freed element straight back off the freelist. hash_np does the same on
a BPF_F_NO_PREALLOC map, which frees to bpf_mem_alloc instead.
hash_replace replaces the key twice, which is how a preallocated map
recycles through its per-CPU spare. reuse arms an element that has already
been deleted and recycled. lru_evict floods a two-entry map and checks the
armed element was not evicted. hash_chain checks a re-arm from the callback
of a deleted element is refused.

stress hammers arm and delete on overlapping keys from several CPUs and
checks every arm ran.

Signed-off-by: Puranjay Mohan <puranjay@kernel.org>
---
 .../selftests/bpf/prog_tests/call_rcu.c       | 254 +++++++++++++++-
 tools/testing/selftests/bpf/progs/call_rcu.c  | 274 ++++++++++++++++++
 2 files changed, 526 insertions(+), 2 deletions(-)

diff --git a/tools/testing/selftests/bpf/prog_tests/call_rcu.c b/tools/testing/selftests/bpf/prog_tests/call_rcu.c
index e4d271d43d878..b60220cdf318c 100644
--- a/tools/testing/selftests/bpf/prog_tests/call_rcu.c
+++ b/tools/testing/selftests/bpf/prog_tests/call_rcu.c
@@ -1,6 +1,7 @@
 // SPDX-License-Identifier: GPL-2.0
 /* Copyright (c) 2026 Meta Platforms, Inc. and affiliates. */
 #include <test_progs.h>
+#include <pthread.h>
 #include "call_rcu.skel.h"
 #include "call_rcu_fail.skel.h"
 
@@ -163,6 +164,158 @@ static void test_call_rcu_teardown(void)
 	call_rcu__destroy(skel);
 }
 
+/*
+ * A hash element armed and then deleted: the element must stay alive until the
+ * callback has run, and the callback must still see its value.
+ */
+static void test_call_rcu_hash_delete(void)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	struct call_rcu *skel;
+	int i;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel_open_and_load"))
+		return;
+
+	if (!ASSERT_OK(bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arm_hash_then_delete),
+					      &opts), "test_run"))
+		goto out;
+	if (!ASSERT_EQ(opts.retval, 0, "retval") ||
+	    !ASSERT_EQ(skel->bss->arm_err, 0, "arm_err"))
+		goto out;
+
+	kern_sync_rcu();
+	for (i = 0; i < 3000; i++) {
+		if (__atomic_load_n(&skel->bss->hash_callbacks, __ATOMIC_ACQUIRE) >= 1)
+			break;
+		usleep(10000);
+	}
+
+	ASSERT_EQ(skel->bss->hash_callbacks, 1, "hash_callbacks");
+	ASSERT_EQ(skel->bss->hash_cb_val, 0xbadc0de, "hash_cb_val");
+out:
+	call_rcu__destroy(skel);
+}
+
+/* A hash callback which re-arms, with the element deleted mid-chain. */
+static void test_call_rcu_hash_chain(void)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	struct call_rcu *skel;
+	int i;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel_open_and_load"))
+		return;
+
+	skel->bss->hash_chain = 3;
+	if (!ASSERT_OK(bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arm_hash_then_delete),
+					      &opts), "test_run") ||
+	    !ASSERT_EQ(opts.retval, 0, "retval"))
+		goto out;
+
+	kern_sync_rcu();
+	for (i = 0; i < 3000; i++) {
+		if (__atomic_load_n(&skel->bss->hash_callbacks, __ATOMIC_ACQUIRE) >= 1)
+			break;
+		usleep(10000);
+	}
+
+	/*
+	 * The delete lands before the first callback, so the chain is cut:
+	 * re-arming a dead head fails and the element is released once.
+	 */
+	ASSERT_EQ(skel->bss->hash_chain_err, -EBUSY, "chain_refused");
+
+	/* A re-arm that slipped through would land within a grace period. */
+	kern_sync_rcu();
+	usleep(100000);
+	ASSERT_EQ(skel->bss->hash_callbacks, 1, "hash_callbacks");
+out:
+	call_rcu__destroy(skel);
+}
+
+/* An LRU element evicted under pressure while its callback is queued. */
+static void test_call_rcu_lru_evict(void)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	struct call_rcu *skel;
+	int i;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel_open_and_load"))
+		return;
+
+	if (!ASSERT_OK(bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arm_lru_then_evict),
+					      &opts), "test_run") ||
+	    !ASSERT_EQ(opts.retval, 0, "retval") ||
+	    !ASSERT_EQ(skel->bss->arm_err, 0, "arm_err"))
+		goto out;
+
+	kern_sync_rcu();
+	for (i = 0; i < 3000; i++) {
+		if (__atomic_load_n(&skel->bss->lru_callbacks, __ATOMIC_ACQUIRE) >= 1)
+			break;
+		usleep(10000);
+	}
+
+	ASSERT_EQ(skel->bss->lru_still_present, 1, "lru_still_present");
+	ASSERT_EQ(skel->bss->lru_callbacks, 1, "lru_callbacks");
+	ASSERT_EQ(skel->bss->lru_cb_val, 0xfeedface, "lru_cb_val");
+out:
+	call_rcu__destroy(skel);
+}
+
+static void *stress_thread(void *arg)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	int i, err, prog_fd = *(int *)arg;
+
+	for (i = 0; i < 200; i++) {
+		err = bpf_prog_test_run_opts(prog_fd, &opts);
+		if (!ASSERT_OK(err, "test_run"))
+			break;
+	}
+
+	return NULL;
+}
+
+/* Concurrent arm and delete on overlapping keys from several CPUs. */
+static void test_call_rcu_stress(void)
+{
+	pthread_t thread[8];
+	struct call_rcu *skel;
+	int i, err, prog_fd;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel_open_and_load"))
+		return;
+
+	prog_fd = bpf_program__fd(skel->progs.stress_hash);
+	for (i = 0; i < ARRAY_SIZE(thread); i++) {
+		err = pthread_create(&thread[i], NULL, stress_thread, &prog_fd);
+		if (!ASSERT_OK(err, "pthread_create"))
+			break;
+	}
+	while (i--)
+		pthread_join(thread[i], NULL);
+
+	for (i = 0; i < 3000; i++) {
+		if (__atomic_load_n(&skel->bss->stress_callbacks, __ATOMIC_ACQUIRE) >=
+		    skel->bss->stress_arms)
+			break;
+		kern_sync_rcu();
+		usleep(10000);
+	}
+
+	/* Every arm must have run, and the run must not have stalled early. */
+	ASSERT_GT(skel->bss->stress_arms, 1000, "stress_arms");
+	ASSERT_EQ(skel->bss->stress_callbacks, skel->bss->stress_arms, "stress_callbacks");
+
+	call_rcu__destroy(skel);
+}
+
 static void test_call_rcu_bad_map(void)
 {
 	LIBBPF_OPTS(bpf_map_create_opts, opts);
@@ -177,9 +330,9 @@ static void test_call_rcu_bad_map(void)
 	opts.btf_key_type_id = bpf_map__btf_key_type_id(skel->maps.arr);
 	opts.btf_value_type_id = bpf_map__btf_value_type_id(skel->maps.arr);
 
-	fd = bpf_map_create(BPF_MAP_TYPE_HASH, "rcu_hash", sizeof(__u32),
+	fd = bpf_map_create(BPF_MAP_TYPE_PERCPU_HASH, "rcu_pcpu", sizeof(__u32),
 			    bpf_map__value_size(skel->maps.arr), 1, &opts);
-	ASSERT_EQ(fd, -EOPNOTSUPP, "hash_rejected");
+	ASSERT_EQ(fd, -EOPNOTSUPP, "percpu_hash_rejected");
 	if (fd >= 0)
 		close(fd);
 
@@ -253,6 +406,89 @@ static void test_call_rcu_two_heads(void)
 	call_rcu__destroy(skel);
 }
 
+static void test_call_rcu_hash_replace(void)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	struct call_rcu *skel;
+	int i;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel"))
+		return;
+
+	if (!ASSERT_OK(bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arm_then_replace),
+					      &opts), "run"))
+		goto out;
+	if (!ASSERT_EQ(opts.retval, 0, "retval"))
+		goto out;
+	ASSERT_OK(skel->bss->arm_err, "arm_err");
+
+	kern_sync_rcu();
+	for (i = 0; i < 3000; i++) {
+		if (__atomic_load_n(&skel->bss->hash_callbacks, __ATOMIC_ACQUIRE) >= 1)
+			break;
+		usleep(10000);
+	}
+	if (!ASSERT_EQ(skel->bss->hash_callbacks, 1, "hash_callbacks"))
+		goto out;
+
+	/*
+	 * The value the callback saw tells us which element it ran on: the one
+	 * that was armed, not one handed back out to a later update.
+	 */
+	ASSERT_EQ(skel->bss->hash_cb_val, 0xbadc0de, "hash_cb_val");
+out:
+	call_rcu__destroy(skel);
+}
+
+static void test_call_rcu_reuse(void)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	struct call_rcu *skel;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel"))
+		return;
+	if (!ASSERT_OK(bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arm_after_reuse), &opts),
+		       "run"))
+		goto out;
+	ASSERT_EQ(opts.retval, 0, "retval");
+	ASSERT_OK(skel->bss->reuse_arm2, "arm_after_reuse");
+out:
+	call_rcu__destroy(skel);
+}
+
+static void test_call_rcu_hash_np(void)
+{
+	LIBBPF_OPTS(bpf_test_run_opts, opts);
+	struct call_rcu *skel;
+	int i;
+
+	skel = call_rcu__open_and_load();
+	if (!ASSERT_OK_PTR(skel, "skel"))
+		return;
+
+	if (!ASSERT_OK(bpf_prog_test_run_opts(bpf_program__fd(skel->progs.arm_delete_reinsert),
+					      &opts), "run"))
+		goto out;
+	if (!ASSERT_EQ(opts.retval, 0, "retval"))
+		goto out;
+	ASSERT_OK(skel->bss->arm_err, "arm_err");
+
+	kern_sync_rcu();
+	for (i = 0; i < 3000; i++) {
+		if (__atomic_load_n(&skel->bss->hash_callbacks, __ATOMIC_ACQUIRE) >= 1)
+			break;
+		usleep(10000);
+	}
+	if (!ASSERT_EQ(skel->bss->hash_callbacks, 1, "hash_callbacks"))
+		goto out;
+
+	ASSERT_EQ(skel->bss->hash_cb_val, 0xbadc0de, "hash_cb_val");
+out:
+	call_rcu__destroy(skel);
+}
+
 void test_call_rcu(void)
 {
 	if (test__start_subtest("run"))
@@ -265,6 +501,20 @@ void test_call_rcu(void)
 		test_call_rcu_two_heads();
 	if (test__start_subtest("teardown"))
 		test_call_rcu_teardown();
+	if (test__start_subtest("hash_delete"))
+		test_call_rcu_hash_delete();
+	if (test__start_subtest("hash_chain"))
+		test_call_rcu_hash_chain();
+	if (test__start_subtest("lru_evict"))
+		test_call_rcu_lru_evict();
+	if (test__start_subtest("hash_replace"))
+		test_call_rcu_hash_replace();
+	if (test__start_subtest("hash_np"))
+		test_call_rcu_hash_np();
+	if (test__start_subtest("reuse"))
+		test_call_rcu_reuse();
+	if (test__start_subtest("stress"))
+		test_call_rcu_stress();
 	if (test__start_subtest("bad_map"))
 		test_call_rcu_bad_map();
 	if (test__start_subtest("iter"))
diff --git a/tools/testing/selftests/bpf/progs/call_rcu.c b/tools/testing/selftests/bpf/progs/call_rcu.c
index 505008647fd6f..f3ad927298005 100644
--- a/tools/testing/selftests/bpf/progs/call_rcu.c
+++ b/tools/testing/selftests/bpf/progs/call_rcu.c
@@ -20,6 +20,31 @@ struct {
 	__type(value, struct elem);
 } arr SEC(".maps");
 
+struct {
+	__uint(type, BPF_MAP_TYPE_HASH);
+	__uint(max_entries, 16384);
+	__type(key, __u32);
+	__type(value, struct elem);
+} hash SEC(".maps");
+
+struct {
+	__uint(type, BPF_MAP_TYPE_HASH);
+	__uint(max_entries, 8);
+	__uint(map_flags, BPF_F_NO_PREALLOC);
+	__type(key, __u32);
+	__type(value, struct elem);
+} hash_np SEC(".maps");
+
+int hash_cb_val;
+int hash_callbacks;
+int hash_chain;		/* set by userspace: re-arms from the hash callback */
+int hash_chain_err;
+int lru_callbacks;
+int lru_still_present;
+int lru_cb_val;
+int stress_callbacks;
+int stress_arms;
+
 __u32 cb_key;
 __u64 cb_keys;
 __u64 cb_val;
@@ -103,6 +128,255 @@ int arm_trace(void *ctx)
 	return 0;
 }
 
+static int hash_reclaim(struct bpf_map *map, void *key, void *value)
+{
+	struct elem *e = value;
+
+	hash_cb_val = e->val;
+
+	if (hash_chain > 0) {
+		hash_chain--;
+		hash_chain_err = bpf_call_rcu(&e->rh, &hash, hash_reclaim);
+	}
+
+	__sync_fetch_and_add(&hash_callbacks, 1);
+	return 0;
+}
+
+struct {
+	__uint(type, BPF_MAP_TYPE_LRU_HASH);
+	__uint(max_entries, 2);
+	__type(key, __u32);
+	__type(value, struct elem);
+} lru SEC(".maps");
+
+static int lru_reclaim(struct bpf_map *map, void *key, void *value)
+{
+	struct elem *e = value;
+
+	lru_cb_val = e->val;
+	__sync_fetch_and_add(&lru_callbacks, 1);
+	return 0;
+}
+
+/*
+ * Arm an LRU element, then push enough traffic through the map to evict it
+ * while the callback is still queued.
+ */
+SEC("syscall")
+int arm_lru_then_evict(void *ctx)
+{
+	struct elem init = {};
+	__u32 key = 100;
+	struct elem *e;
+	int i;
+
+	init.val = 0xfeedface;
+	if (bpf_map_update_elem(&lru, &key, &init, BPF_ANY))
+		return 1;
+
+	e = bpf_map_lookup_elem(&lru, &key);
+	if (!e)
+		return 2;
+
+	arm_err = bpf_call_rcu(&e->rh, &lru, lru_reclaim);
+	if (arm_err)
+		return 3;
+
+	/* max_entries is 2, so this would evict the armed element. */
+	init.val = 0;
+	bpf_for(i, 0, 64) {
+		__u32 k = 200 + i;
+
+		bpf_map_update_elem(&lru, &k, &init, BPF_ANY);
+	}
+
+	/* The callback still needs it, so the eviction must have been declined. */
+	lru_still_present = !!bpf_map_lookup_elem(&lru, &key);
+	return 0;
+}
+
+static int stress_reclaim(struct bpf_map *map, void *key, void *value)
+{
+	__sync_fetch_and_add(&stress_callbacks, 1);
+	return 0;
+}
+
+/*
+ * Hammer arm and delete on overlapping keys from several CPUs at once, to
+ * exercise the window where a delete lands while a callback is running.
+ */
+SEC("syscall")
+int stress_hash(void *ctx)
+{
+	struct elem init = {};
+	struct elem *e;
+	__u32 key;
+	int i;
+
+	init.val = 0xabcd1234;
+
+	bpf_for(i, 0, 16) {
+		key = bpf_get_prandom_u32() & 0xff;
+
+		if (bpf_map_update_elem(&hash, &key, &init, BPF_ANY))
+			continue;
+
+		e = bpf_map_lookup_elem(&hash, &key);
+		if (!e)
+			continue;
+
+		if (!bpf_call_rcu(&e->rh, &hash, stress_reclaim))
+			__sync_fetch_and_add(&stress_arms, 1);
+		bpf_map_delete_elem(&hash, &key);
+	}
+
+	return 0;
+}
+
+/*
+ * Arm a hash element, then replace its key twice. On a preallocated map the
+ * first replace would stash the old element in this CPU's spare and the second
+ * would hand that same element straight back out, so a callback that still
+ * needs it must take it out of circulation instead.
+ */
+SEC("syscall")
+int arm_then_replace(void *ctx)
+{
+	struct elem init = {};
+	__u32 key = 7;
+	struct elem *e;
+
+	init.val = 0xbadc0de;
+	if (bpf_map_update_elem(&hash, &key, &init, BPF_ANY))
+		return 1;
+
+	e = bpf_map_lookup_elem(&hash, &key);
+	if (!e)
+		return 2;
+
+	arm_err = bpf_call_rcu(&e->rh, &hash, hash_reclaim);
+	if (arm_err)
+		return 3;
+
+	init.val = 0xaaaa;
+	if (bpf_map_update_elem(&hash, &key, &init, BPF_ANY))
+		return 4;
+	init.val = 0xbbbb;
+	if (bpf_map_update_elem(&hash, &key, &init, BPF_ANY))
+		return 5;
+
+	return 0;
+}
+
+static int hash_np_reclaim(struct bpf_map *map, void *key, void *value)
+{
+	struct elem *e = value;
+
+	hash_cb_val = e->val;
+	__sync_fetch_and_add(&hash_callbacks, 1);
+	return 0;
+}
+
+/*
+ * Same handover on a map that frees elements to bpf_mem_alloc rather than a
+ * freelist: arm, delete, then insert the key again. The callback must still
+ * see the value it was armed on, not whatever the reinsert wrote.
+ */
+SEC("syscall")
+int arm_delete_reinsert(void *ctx)
+{
+	struct elem init = {};
+	__u32 key = 3;
+	struct elem *e;
+
+	init.val = 0xbadc0de;
+	if (bpf_map_update_elem(&hash_np, &key, &init, BPF_ANY))
+		return 1;
+
+	e = bpf_map_lookup_elem(&hash_np, &key);
+	if (!e)
+		return 2;
+
+	arm_err = bpf_call_rcu(&e->rh, &hash_np, hash_np_reclaim);
+	if (arm_err)
+		return 3;
+
+	if (bpf_map_delete_elem(&hash_np, &key))
+		return 4;
+
+	init.val = 0x1234;
+	if (bpf_map_update_elem(&hash_np, &key, &init, BPF_ANY))
+		return 5;
+
+	return 0;
+}
+
+/* Arm a hash element, then delete it before the callback can run. */
+SEC("syscall")
+int arm_hash_then_delete(void *ctx)
+{
+	struct elem init = {};
+	__u32 key = 7;
+	struct elem *e;
+
+	init.val = 0xbadc0de;
+	if (bpf_map_update_elem(&hash, &key, &init, BPF_ANY))
+		return 1;
+
+	e = bpf_map_lookup_elem(&hash, &key);
+	if (!e)
+		return 2;
+
+	arm_err = bpf_call_rcu(&e->rh, &hash, hash_reclaim);
+	if (arm_err)
+		return 3;
+
+	if (bpf_map_delete_elem(&hash, &key))
+		return 4;
+
+	/* The freelist is LIFO, so this would hand the same element back out. */
+	key = 8;
+	init.val = 0x5678;
+	return bpf_map_update_elem(&hash, &key, &init, BPF_ANY) ? 5 : 0;
+}
+
+struct {
+	__uint(type, BPF_MAP_TYPE_HASH);
+	__uint(max_entries, 1);
+	__type(key, __u32);
+	__type(value, struct elem);
+} one SEC(".maps");
+
+int reuse_arm1, reuse_arm2;
+
+static int reuse_cb(struct bpf_map *map, void *key, void *value)
+{
+	return 0;
+}
+
+/* Insert, delete without ever arming, reinsert, then arm the recycled element. */
+SEC("syscall")
+int arm_after_reuse(void *ctx)
+{
+	struct elem init = {};
+	__u32 key = 1;
+	struct elem *e;
+
+	if (bpf_map_update_elem(&one, &key, &init, BPF_ANY))
+		return 1;
+	/* No callback is ever armed on this element before the delete. */
+	if (bpf_map_delete_elem(&one, &key))
+		return 3;
+	if (bpf_map_update_elem(&one, &key, &init, BPF_ANY))
+		return 4;
+	e = bpf_map_lookup_elem(&one, &key);
+	if (!e)
+		return 5;
+	reuse_arm2 = bpf_call_rcu(&e->rh, &one, reuse_cb);
+	return 0;
+}
+
 SEC("iter/bpf_map_elem")
 int dump(struct bpf_iter__bpf_map_elem *ctx)
 {
-- 
2.53.0-Meta


  parent reply	other threads:[~2026-09-24 16:29 UTC|newest]

Thread overview: 7+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-24 16:28 [PATCH bpf-next 0/2] bpf: Support bpf_rcu_head in hash and LRU hash maps Puranjay Mohan
2026-09-24 16:28 ` [PATCH bpf-next 1/2] " Puranjay Mohan
2026-09-24 16:52   ` sashiko-bot
2026-09-24 17:24   ` bot+bpf-ci
2026-09-25  1:05   ` Alexei Starovoitov
2026-09-24 16:28 ` Puranjay Mohan [this message]
2026-09-24 17:23   ` [PATCH bpf-next 2/2] selftests/bpf: Add bpf_call_rcu tests for " bot+bpf-ci

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260924162858.2435106-3-puranjay@kernel.org \
    --to=puranjay@kernel.org \
    --cc=andrii@kernel.org \
    --cc=ast@kernel.org \
    --cc=bpf@vger.kernel.org \
    --cc=daniel@iogearbox.net \
    --cc=eddyz87@gmail.com \
    --cc=martin.lau@linux.dev \
    --cc=memxor@gmail.com \
    --cc=song@kernel.org \
    --cc=yonghong.song@linux.dev \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox