aboutsummaryrefslogtreecommitdiff
path: root/working/keyring-crash
diff options
context:
space:
mode:
Diffstat (limited to 'working/keyring-crash')
-rw-r--r--working/keyring-crash/deploy-ratio.sh65
-rw-r--r--working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch275
-rw-r--r--working/keyring-crash/probe.py300
-rwxr-xr-xworking/keyring-crash/run-alias-only.sh23
-rwxr-xr-xworking/keyring-crash/run-package-test.sh24
-rwxr-xr-xworking/keyring-crash/run-regression.sh50
-rwxr-xr-xworking/keyring-crash/run-upstream-tests.sh11
-rw-r--r--working/keyring-crash/setup-guest.sh40
-rw-r--r--working/keyring-crash/status.org118
-rwxr-xr-xworking/keyring-crash/vm-run.sh18
10 files changed, 924 insertions, 0 deletions
diff --git a/working/keyring-crash/deploy-ratio.sh b/working/keyring-crash/deploy-ratio.sh
new file mode 100644
index 0000000..f86e290
--- /dev/null
+++ b/working/keyring-crash/deploy-ratio.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Apply the patched gnome-keyring package to a live host (ratio, later velox).
+#
+# Runs only after the VM gates passed and only when Craig says go. Every step
+# is reversible: the encrypted keyring files are backed up with their modes,
+# the stock package stays in the pacman cache for a one-command rollback, and
+# only the keyring service is restarted (one unlock prompt is expected, to be
+# answered in the normal system dialog, never in chat).
+#
+# deploy-ratio.sh build build the package from the repo recipe (as the user)
+# deploy-ratio.sh install back up, install, restart the daemon, verify
+# deploy-ratio.sh rollback reinstall the cached stock 1:51.1-1 and restart
+# deploy-ratio.sh verify show versions, owner, restart counter, recent crashes
+set -euo pipefail
+
+repo="${ARCHSETUP_DIR:-$HOME/code/archsetup}"
+recipe="$repo/packages/gnome-keyring"
+build_dir="${BUILD_DIR:-$HOME/.cache/archsetup-build/gnome-keyring}"
+stock_pkg="/var/cache/pacman/pkg/gnome-keyring-1:51.1-1-x86_64.pkg.tar.zst"
+backup_root="$HOME/.local/share/keyrings-backup"
+
+verify() {
+ echo "host: $(uname -n)"
+ pacman -Q gnome-keyring glib2
+ systemctl --user show gnome-keyring-daemon.service -p ActiveState -p NRestarts -p MainPID -p ExecMainStartTimestamp
+ busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)=' || echo "no Secret Service owner"
+ echo "aborts in the last hour: $(journalctl --user -u gnome-keyring-daemon.service --since '1 hour ago' --no-pager 2>/dev/null | grep -c 'Scheduled restart job')"
+ sha256sum /usr/bin/gnome-keyring-daemon
+}
+
+case "${1:-}" in
+build)
+ [ -f "$recipe/PKGBUILD" ] || { echo "recipe missing: $recipe" >&2; exit 1; }
+ rm -rf "$build_dir"; mkdir -p "$build_dir"; cp -a "$recipe/." "$build_dir/"
+ (cd "$build_dir" && makepkg --noconfirm -s)
+ ls -la "$build_dir"/*.pkg.tar.zst
+ ;;
+install)
+ pkg=$(ls "$build_dir"/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst)
+ [ -f "$stock_pkg" ] || { echo "stock package not in cache for rollback: $stock_pkg" >&2; exit 1; }
+ # Permission-protected backup of the encrypted keyring files, modes kept.
+ stamp=$(date +%Y%m%d-%H%M%S)
+ mkdir -p "$backup_root"; chmod 700 "$backup_root"
+ cp -a "$HOME/.local/share/keyrings" "$backup_root/keyrings-$stamp"
+ chmod -R go-rwx "$backup_root"
+ echo "backup: $backup_root/keyrings-$stamp"
+ sudo pacman -U --noconfirm "$pkg"
+ # Only the keyring service restarts; the socket unit stays. One unlock
+ # prompt is expected afterwards.
+ systemctl --user restart gnome-keyring-daemon.service
+ sleep 2
+ verify
+ ;;
+rollback)
+ sudo pacman -U --noconfirm "$stock_pkg"
+ systemctl --user restart gnome-keyring-daemon.service
+ sleep 2
+ verify
+ ;;
+verify)
+ verify
+ ;;
+*)
+ sed -n '2,13p' "$0"; exit 2 ;;
+esac
diff --git a/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch
new file mode 100644
index 0000000..9ef298b
--- /dev/null
+++ b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch
@@ -0,0 +1,275 @@
+diff -ruN a/daemon/dbus/gkd-secret-objects.c b/daemon/dbus/gkd-secret-objects.c
+--- a/daemon/dbus/gkd-secret-objects.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-objects.c 2026-10-17 06:47:19.275009074 -0500
+@@ -430,7 +430,11 @@
+
+ /* The session we're using to access the object */
+ session = gkd_secret_service_get_pkcs11_session (self->service, sender);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_free (c_ident);
++ g_free (i_ident);
++ goto out;
++ }
+
+ if (i_ident) {
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY);
+@@ -1055,17 +1059,23 @@
+ if (!gkd_secret_util_parse_path (path, &identifier, NULL))
+ return NULL;
+
+- if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX))
+- real_identifier = gkd_secret_service_get_alias (self->service, identifier);
+- else
+- real_identifier = identifier;
+-
+- /* The session we're using to access the object */
++ /* The session we're using to access the object. Resolve it before the
++ * alias: fetching a caller's session may register the caller, which
++ * refreshes the default alias and frees the string the alias table
++ * held, so an alias looked up earlier would dangle here. */
+ if (caller == NULL)
+ session = gkd_secret_service_internal_pkcs11_session (self->service);
+ else
+ session = gkd_secret_service_get_pkcs11_session (self->service, caller);
+- g_return_val_if_fail (session, NULL);
++ if (session == NULL) {
++ g_free (identifier);
++ return NULL;
++ }
++
++ if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX))
++ real_identifier = gkd_secret_service_get_alias (self->service, identifier);
++ else
++ real_identifier = identifier;
+
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_G_COLLECTION);
+ gck_builder_add_string (&builder, CKA_ID, real_identifier);
+diff -ruN a/daemon/dbus/gkd-secret-service.c b/daemon/dbus/gkd-secret-service.c
+--- a/daemon/dbus/gkd-secret-service.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-service.c 2026-10-17 06:47:19.274715149 -0500
+@@ -137,6 +137,9 @@
+ GHashTable *clients;
+ GkdSecretObjects *objects;
+ GHashTable *aliases;
++ /* The message filter reads aliases on the GDBus worker thread while
++ * the main thread updates them; every access goes through this lock. */
++ GMutex aliases_lock;
+ GckSession *internal_session;
+ gchar *default_path;
+ };
+@@ -190,6 +193,18 @@
+ return g_build_filename (alias_directory, "default", NULL);
+ }
+
++/* An owned copy of an alias, safe to call from any thread. */
++static gchar *
++lookup_alias_copy (GkdSecretService *self, const gchar *alias)
++{
++ gchar *copy;
++
++ g_mutex_lock (&self->aliases_lock);
++ copy = g_strdup (g_hash_table_lookup (self->aliases, alias));
++ g_mutex_unlock (&self->aliases_lock);
++ return copy;
++}
++
+ static void
+ update_default (GkdSecretService *self)
+ {
+@@ -206,21 +221,31 @@
+ /* Default to to 'login' if no default keyring */
+ if (contents == NULL)
+ contents = g_strdup ("login");
+- g_hash_table_replace (self->aliases, g_strdup ("default"), contents);
++
++ /* Replace only on a real change: callers on the main thread hold the
++ * previous value borrowed, and the common case (another client
++ * registering) must not free it out from under them. */
++ g_mutex_lock (&self->aliases_lock);
++ if (g_strcmp0 (g_hash_table_lookup (self->aliases, "default"), contents) == 0)
++ g_free (contents);
++ else
++ g_hash_table_replace (self->aliases, g_strdup ("default"), contents);
++ g_mutex_unlock (&self->aliases_lock);
+ }
+
+ static void
+ store_default (GkdSecretService *self)
+ {
+ GError *error = NULL;
+- const gchar *identifier;
++ gchar *identifier;
+
+- identifier = g_hash_table_lookup (self->aliases, "default");
++ identifier = lookup_alias_copy (self, "default");
+ if (!identifier)
+ return;
+
+ if (!g_file_set_contents (self->default_path, identifier, -1, &error))
+ g_message ("couldn't store default keyring: %s", egg_error_message (error));
++ g_free (identifier);
+ }
+
+ static gboolean
+@@ -331,7 +356,7 @@
+ GDBusMessage *message)
+ {
+ const char *path = g_dbus_message_get_path (message);
+- const char *replace;
++ char *replace;
+ char *collection = NULL, *item = NULL;
+ char *collection_path, *item_path;
+ GDBusMessage *rewritten;
+@@ -346,7 +371,9 @@
+ if (!gkd_secret_util_parse_path (path, &collection, &item))
+ return message;
+
+- replace = gkd_secret_service_get_alias (self, collection);
++ /* This runs on the GDBus worker thread: take a copy under the lock
++ * rather than borrowing from a table the main thread may be updating. */
++ replace = lookup_alias_copy (self, collection);
+ if (!replace) {
+ g_free (item);
+ g_free (collection);
+@@ -356,6 +383,9 @@
+ rewritten = g_dbus_message_copy (message, &error);
+ if (error != NULL) {
+ g_error_free (error);
++ g_free (replace);
++ g_free (item);
++ g_free (collection);
+ return message;
+ }
+
+@@ -372,6 +402,7 @@
+ }
+
+ g_free (collection_path);
++ g_free (replace);
+ g_free (item);
+ g_free (collection);
+ g_object_unref (message);
+@@ -424,18 +455,30 @@
+ GError *error = NULL;
+ const gchar *caller;
+ GVariant *input_payload;
++ gboolean opened;
+
+ caller = g_dbus_method_invocation_get_sender (invocation);
+
+ /* Now we can create a session with this information */
+ session = gkd_secret_session_new (self, caller);
+ input_payload = g_variant_get_variant (input);
+- gkd_secret_session_handle_open (session, algorithm, input_payload,
+- &output, &result,
+- &error);
++ opened = gkd_secret_session_handle_open (session, algorithm, input_payload,
++ &output, &result,
++ &error);
+ g_variant_unref (input_payload);
+
+- if (error != NULL) {
++ /* Branch on the return value, not on the error alone: a negotiate helper
++ * that bails out through g_return_val_if_fail() reports failure without
++ * ever setting one, and completing the call with a NULL body makes
++ * g_variant_new() abort the whole daemon. */
++ if (!opened || output == NULL || result == NULL) {
++ if (error == NULL)
++ g_set_error_literal (&error, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ if (output != NULL)
++ g_variant_unref (g_variant_ref_sink (output));
++ g_free (result);
+ g_dbus_method_invocation_take_error (invocation, error);
+ } else {
+ gkd_secret_service_publish_dispatch (self, caller,
+@@ -986,6 +1029,7 @@
+ static void
+ gkd_secret_service_init_aliases (GkdSecretService *self)
+ {
++ g_mutex_init (&self->aliases_lock);
+ self->aliases = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+ g_hash_table_insert (self->aliases, g_strdup ("session"), g_strdup ("session"));
+ /* TODO: We should be using CKA_G_LOGIN_COLLECTION */
+@@ -1151,6 +1195,7 @@
+
+ g_hash_table_destroy (self->aliases);
+ self->aliases = NULL;
++ g_mutex_clear (&self->aliases_lock);
+
+ g_free (self->default_path);
+ self->default_path = NULL;
+@@ -1283,7 +1328,18 @@
+ g_return_val_if_fail (caller, NULL);
+
+ client = g_hash_table_lookup (self->clients, caller);
+- g_return_val_if_fail (client, NULL);
++ if (client == NULL) {
++ /* The record is normally created by a high priority idle queued from
++ * the message filter, but that idle is not ordered against the method
++ * call that queued it: a peer which connects and calls straight away
++ * can reach us first. Create it here instead of failing the call --
++ * it is a hash table insert, not I/O. Callers must not hold borrowed
++ * alias strings across this call: registering a client refreshes the
++ * default alias. */
++ gkd_secret_service_ensure_client (self, caller);
++ client = g_hash_table_lookup (self->clients, caller);
++ g_return_val_if_fail (client, NULL);
++ }
+
+ /* Open a new session if necessary */
+ if (!client->pkcs11_session) {
+@@ -1399,7 +1455,9 @@
+ g_return_if_fail (GKD_SECRET_IS_SERVICE (self));
+ g_return_if_fail (alias);
+
++ g_mutex_lock (&self->aliases_lock);
+ g_hash_table_replace (self->aliases, g_strdup (alias), g_strdup (identifier));
++ g_mutex_unlock (&self->aliases_lock);
+
+ if (g_str_equal (alias, "default"))
+ store_default (self);
+@@ -1418,6 +1476,10 @@
+
+ /* Take ownership of the session */
+ client = g_hash_table_lookup (self->clients, caller);
++ if (client == NULL) {
++ gkd_secret_service_ensure_client (self, caller);
++ client = g_hash_table_lookup (self->clients, caller);
++ }
+ g_return_if_fail (client);
+ path = gkd_secret_dispatch_get_object_path (object);
+ g_return_if_fail (!g_hash_table_lookup (client->dispatch, path));
+diff -ruN a/daemon/dbus/gkd-secret-session.c b/daemon/dbus/gkd-secret-session.c
+--- a/daemon/dbus/gkd-secret-session.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-session.c 2026-10-17 06:47:19.270295281 -0500
+@@ -183,7 +183,12 @@
+ gboolean ret;
+
+ session = gkd_secret_service_get_pkcs11_session (self->service, self->caller);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_set_error_literal (error_out, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ return FALSE;
++ }
+
+ if (!aes_create_dh_keys (session, "ietf-ike-grp-modp-1024", &pub, &priv)) {
+ g_set_error_literal (error_out, G_DBUS_ERROR,
+@@ -250,7 +255,12 @@
+ GckSession *session;
+
+ session = gkd_secret_service_get_pkcs11_session (self->service, self->caller);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_set_error_literal (error_out, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ return FALSE;
++ }
+
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY);
+ gck_builder_add_ulong (&builder, CKA_KEY_TYPE, CKK_G_NULL);
diff --git a/working/keyring-crash/probe.py b/working/keyring-crash/probe.py
new file mode 100644
index 0000000..b18f7a7
--- /dev/null
+++ b/working/keyring-crash/probe.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+"""Secret Service stress probe for the disposable keyring guest only.
+
+Every mode drives real GIO D-Bus clients against org.freedesktop.secrets and
+reports what the daemon actually answered, not just that messages were sent.
+The guard at the top refuses to run anywhere but the disposable guest.
+
+Modes:
+ plain OpenSession(plain) then Close, synchronous, one per client
+ dh OpenSession(dh-ietf1024-sha256-aes128-cbc-pkcs7) then Close
+ invalid OpenSession with an unsupported algorithm; expects NotSupported
+ disconnect fire --burst OpenSession calls and drop the connection, no reply read
+ pipeline fire --burst OpenSession calls, await and validate every reply,
+ then Close every session
+ alias a fresh client's first calls are Unlock, Lock and ReadAlias on the
+ default alias path (the lazy client registration happens inside
+ these calls; exercises the alias-string lifetime)
+ isolation client A opens a session; client B must not be able to Close it;
+ A then closes it and B's own session still works
+
+Each batch of --batch clients is followed by a daemon round trip (ReadAlias plus
+the collection's Locked property), so the counters reflect requests the daemon
+actually processed. Exit status is 1 on any error or on a change of bus owner.
+"""
+import argparse
+import collections
+import concurrent.futures
+import json
+import os
+import socket
+import sys
+import time
+
+import gi
+
+gi.require_version("Gio", "2.0")
+from gi.repository import Gio, GLib # noqa: E402
+
+SERVICE = "org.freedesktop.secrets"
+SERVICE_PATH = "/org/freedesktop/secrets"
+SERVICE_IFACE = "org.freedesktop.Secret.Service"
+SESSION_IFACE = "org.freedesktop.Secret.Session"
+COLLECTION_IFACE = "org.freedesktop.Secret.Collection"
+ALIAS_PATH = "/org/freedesktop/secrets/aliases/default"
+LOGIN_PATH = "/org/freedesktop/secrets/collection/login"
+DH_ALGORITHM = "dh-ietf1024-sha256-aes128-cbc-pkcs7"
+TIMEOUT_MS = 10000
+
+
+def connect():
+ conn = Gio.DBusConnection.new_for_address_sync(
+ os.environ["DBUS_SESSION_BUS_ADDRESS"],
+ Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT
+ | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
+ None,
+ None,
+ )
+ conn.set_exit_on_close(False)
+ return conn
+
+
+def call(conn, path, iface, method, args, reply_type):
+ return conn.call_sync(
+ SERVICE, path, iface, method, args,
+ GLib.VariantType.new(reply_type) if reply_type else None,
+ Gio.DBusCallFlags.NONE, TIMEOUT_MS, None,
+ )
+
+
+def owner(conn):
+ return conn.call_sync(
+ "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
+ "GetNameOwner", GLib.Variant("(s)", (SERVICE,)),
+ GLib.VariantType.new("(s)"), Gio.DBusCallFlags.NONE, 5000, None,
+ ).unpack()[0]
+
+
+def barrier(conn):
+ """Await the daemon itself, not only the message bus, after each batch."""
+ path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias",
+ GLib.Variant("(s)", ("default",)), "(o)").unpack()[0]
+ if path != LOGIN_PATH:
+ raise RuntimeError("Synthetic login collection disappeared")
+ locked = conn.call_sync(
+ SERVICE, path, "org.freedesktop.DBus.Properties", "Get",
+ GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")),
+ GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None,
+ ).unpack()[0]
+ if locked:
+ raise RuntimeError("Synthetic login collection became locked")
+
+
+def open_session_body(mode):
+ algorithm = "plain" if mode in ("plain", "disconnect", "pipeline", "isolation") else DH_ALGORITHM
+ payload = GLib.Variant("s", "") if algorithm == "plain" else GLib.Variant("ay", [2])
+ if mode == "invalid":
+ algorithm = "intentionally-unsupported"
+ return GLib.Variant("(sv)", (algorithm, payload))
+
+
+def check_open_reply(reply):
+ """An OpenSession reply must carry a session object path under the service."""
+ _output, path = reply.unpack()
+ if not isinstance(path, str) or not path.startswith(SERVICE_PATH + "/session/"):
+ raise RuntimeError(f"OpenSession returned an invalid session path: {path!r}")
+ return path
+
+
+def close_session(conn, path):
+ call(conn, path, SESSION_IFACE, "Close", None, None)
+
+
+def run_sync_client(conn, mode):
+ try:
+ reply = call(conn, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body(mode), "(vo)")
+ except GLib.Error as exc:
+ if mode == "invalid" and Gio.DBusError.get_remote_error(exc) == "org.freedesktop.DBus.Error.NotSupported":
+ return "expected-not-supported"
+ raise
+ if mode == "invalid":
+ raise RuntimeError("Unsupported algorithm unexpectedly accepted")
+ close_session(conn, check_open_reply(reply))
+ return "ok"
+
+
+def run_disconnect_client(conn, burst):
+ for _ in range(burst):
+ message = Gio.DBusMessage.new_method_call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession")
+ message.set_body(open_session_body("disconnect"))
+ conn.send_message(message, Gio.DBusSendMessageFlags.NONE)
+ conn.flush_sync(None)
+ return "ok"
+
+
+def run_pipeline_client(conn, burst):
+ """Queue --burst calls without waiting, then collect and validate every reply."""
+ loop = GLib.MainLoop()
+ pending = {"left": burst, "paths": [], "errors": []}
+
+ def done(source, result):
+ try:
+ pending["paths"].append(check_open_reply(source.call_finish(result)))
+ except Exception as exc: # noqa: BLE001 - recorded, then raised below
+ pending["errors"].append(str(exc))
+ pending["left"] -= 1
+ if pending["left"] == 0:
+ loop.quit()
+
+ for _ in range(burst):
+ conn.call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body("pipeline"), GLib.VariantType.new("(vo)"),
+ Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, done)
+ GLib.timeout_add(TIMEOUT_MS + 2000, loop.quit)
+ loop.run()
+ if pending["left"]:
+ raise RuntimeError(f"{pending['left']} pipelined OpenSession calls never answered")
+ if pending["errors"]:
+ raise RuntimeError("pipelined OpenSession failed: " + pending["errors"][0])
+ if len(set(pending["paths"])) != burst:
+ raise RuntimeError("pipelined sessions did not get distinct paths")
+ for path in pending["paths"]:
+ close_session(conn, path)
+ return "ok"
+
+
+def run_alias_client(conn):
+ """First calls on a fresh connection all go through the alias path.
+
+ Unlock on an already-unlocked collection is a no-op that still resolves
+ the alias inside the daemon (the alias-in-arguments path the message
+ filter does not rewrite). Lock is deliberately not used: it would lock the
+ fixture's login collection, and unlocking again needs a password prompt
+ the headless guest cannot show.
+ """
+ unlocked, prompt = call(conn, SERVICE_PATH, SERVICE_IFACE, "Unlock",
+ GLib.Variant("(ao)", ([ALIAS_PATH],)), "(aoo)").unpack()
+ if unlocked != [ALIAS_PATH] or prompt != "/":
+ raise RuntimeError(f"Unlock via alias answered {unlocked!r}, prompt {prompt!r}")
+ path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias",
+ GLib.Variant("(s)", ("default",)), "(o)").unpack()[0]
+ if path != LOGIN_PATH:
+ raise RuntimeError(f"ReadAlias answered {path!r}")
+ # A property read addressed to the alias path: the filter rewrites the
+ # object path, then the property getter looks the collection up.
+ locked = conn.call_sync(
+ SERVICE, ALIAS_PATH, "org.freedesktop.DBus.Properties", "Get",
+ GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")),
+ GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None,
+ ).unpack()[0]
+ if locked:
+ raise RuntimeError("login collection reported locked through the alias path")
+ return "ok"
+
+
+def run_isolation_client(conn_a):
+ conn_b = connect()
+ try:
+ path_a = check_open_reply(call(conn_a, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body("isolation"), "(vo)"))
+ path_b = check_open_reply(call(conn_b, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body("isolation"), "(vo)"))
+ if path_a == path_b:
+ raise RuntimeError("two clients received the same session path")
+ try:
+ close_session(conn_b, path_a)
+ except GLib.Error:
+ pass
+ else:
+ raise RuntimeError("client B was allowed to Close client A's session")
+ # A's session must still be alive after B's attempt: a second Close by A
+ # must succeed once (the session exists), and B's own session still closes.
+ close_session(conn_a, path_a)
+ close_session(conn_b, path_b)
+ return "ok"
+ finally:
+ conn_b.close_sync(None)
+
+
+def run_client(mode, burst):
+ conn = connect()
+ try:
+ if mode in ("plain", "dh", "invalid"):
+ return run_sync_client(conn, mode)
+ if mode == "disconnect":
+ return run_disconnect_client(conn, burst)
+ if mode == "pipeline":
+ return run_pipeline_client(conn, burst)
+ if mode == "alias":
+ return run_alias_client(conn)
+ if mode == "isolation":
+ return run_isolation_client(conn)
+ raise ValueError(mode)
+ finally:
+ conn.close_sync(None)
+
+
+def daemon_rss_kib(conn):
+ """RSS of the current bus owner, read via its PID; None when unavailable."""
+ try:
+ pid = conn.call_sync(
+ "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
+ "GetConnectionUnixProcessID", GLib.Variant("(s)", (SERVICE,)),
+ GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None,
+ ).unpack()[0]
+ with open(f"/proc/{pid}/status") as status:
+ for line in status:
+ if line.startswith("VmRSS:"):
+ return int(line.split()[1])
+ except (GLib.Error, OSError):
+ return None
+ return None
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("mode", choices=("plain", "dh", "invalid", "disconnect", "pipeline", "alias", "isolation"))
+ parser.add_argument("--clients", type=int, default=200)
+ parser.add_argument("--workers", type=int, default=16)
+ parser.add_argument("--burst", type=int, default=1)
+ parser.add_argument("--batch", type=int, default=100)
+ args = parser.parse_args()
+ if socket.gethostname() != "archsetup-test" or os.getuid() != 1000:
+ raise SystemExit("Refusing to run outside the disposable archsetup-test guest as UID 1000")
+ monitor = connect()
+ initial_owner = owner(monitor)
+ rss_before = daemon_rss_kib(monitor)
+ results = collections.Counter()
+ examples = []
+ start = time.monotonic()
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
+ for offset in range(0, args.clients, args.batch):
+ futures = [pool.submit(run_client, args.mode, args.burst)
+ for _ in range(min(args.batch, args.clients - offset))]
+ for future in concurrent.futures.as_completed(futures):
+ try:
+ results[future.result()] += 1
+ except Exception as exc: # noqa: BLE001 - counted and reported
+ results[type(exc).__name__] += 1
+ if len(examples) < 5:
+ examples.append(str(exc))
+ barrier(monitor)
+ try:
+ final_owner = owner(monitor)
+ except GLib.Error as exc:
+ final_owner = str(exc)
+ rss_after = daemon_rss_kib(monitor)
+ monitor.close_sync(None)
+ print(json.dumps({
+ "mode": args.mode, "clients": args.clients, "workers": args.workers,
+ "burst": args.burst, "seconds": round(time.monotonic() - start, 2),
+ "initial_owner": initial_owner, "final_owner": final_owner,
+ "rss_kib_before": rss_before, "rss_kib_after": rss_after,
+ "results": dict(results), "errors": examples,
+ }), flush=True)
+ return 1 if examples or initial_owner != final_owner else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/working/keyring-crash/run-alias-only.sh b/working/keyring-crash/run-alias-only.sh
new file mode 100755
index 0000000..7816066
--- /dev/null
+++ b/working/keyring-crash/run-alias-only.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Alias-path focus run: reset the fixture with the given daemon and drive only
+# the alias mode hard (the lazy-registration race under Lock/Unlock via alias).
+set -uo pipefail
+label="$1"; daemon="$2"; asan="${3:-}"; clients="${4:-8000}"
+here="$(cd "$(dirname "$0")" && pwd)"
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; }
+gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; }
+as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus'
+echo "=== $label alias-only daemon=$daemon asan=${asan:-none} $(date -Is)"
+gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null
+g "rm -f /tmp/asan.log*; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" >/dev/null || { echo "FIXTURE FAILED"; exit 1; }
+since=$(g 'date "+%Y-%m-%d %H:%M:%S"')
+g "$as_user python3 /opt/keyring-vm/probe.py alias --clients $clients --workers 32 --batch 100" 2>&1 | tail -2; rc=${PIPESTATUS[0]}
+echo "alias rc=$rc"
+secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null); echo "secret after alias: ${secret:-empty}"
+echo "--- journal"; g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring' | grep -iE 'assert|abort|critical|sanitizer|dumped' | head -20"
+[ -n "$asan" ] && { echo "--- asan"; g 'cat /tmp/asan.log* 2>/dev/null | head -60 || echo "no asan log"'; }
+echo "=== $label alias-only result: $([ "$rc" = 0 ] && [ "$secret" = synthetic-vm-secret ] && echo PASS || echo FAIL)"
diff --git a/working/keyring-crash/run-package-test.sh b/working/keyring-crash/run-package-test.sh
new file mode 100755
index 0000000..06a661c
--- /dev/null
+++ b/working/keyring-crash/run-package-test.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Build the repo's gnome-keyring package in the guest with makepkg, install it
+# with pacman, and run the regression through the installed daemon.
+set -uo pipefail
+here="$(cd "$(dirname "$0")" && pwd)"
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; }
+gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; }
+echo "=== package build $(date -Is)"
+g 'rm -rf /home/keyring-test/pkgbuild; mkdir -p /home/keyring-test/pkgbuild'
+gcp -r "$here/../../packages/gnome-keyring/." root@127.0.0.1:/home/keyring-test/pkgbuild/ >/dev/null
+# the tarball is already on the guest; makepkg verifies its checksum anyway
+g 'cp /root/gnome-keyring-51.1.tar.xz /home/keyring-test/pkgbuild/; chown -R keyring-test: /home/keyring-test/pkgbuild
+pacman -S --noconfirm --needed base-devel docbook-xsl git glib2-devel meson p11-kit python >/tmp/deps.log 2>&1 || { echo "DEPS FAILED"; tail -5 /tmp/deps.log; exit 1; }
+cd /home/keyring-test/pkgbuild && runuser -u keyring-test -- makepkg --noconfirm --skippgpcheck >/tmp/makepkg.log 2>&1 || { echo "MAKEPKG FAILED"; tail -30 /tmp/makepkg.log; exit 1; }
+ls -la /home/keyring-test/pkgbuild/*.pkg.tar.zst
+grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/makepkg.log | tr "\n" " "; echo
+pacman -U --noconfirm /home/keyring-test/pkgbuild/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst >/tmp/pacman-u.log 2>&1 || { echo "PACMAN -U FAILED"; tail -5 /tmp/pacman-u.log; exit 1; }
+pacman -Q gnome-keyring; sha256sum /usr/bin/gnome-keyring-daemon'
+echo "=== regression via installed daemon"
+"$here/run-regression.sh" installed /usr/bin/gnome-keyring-daemon
diff --git a/working/keyring-crash/run-regression.sh b/working/keyring-crash/run-regression.sh
new file mode 100755
index 0000000..a853e40
--- /dev/null
+++ b/working/keyring-crash/run-regression.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Host-side regression driver for the disposable keyring guest.
+# run-regression.sh <label> <daemon-binary-in-guest> [ASAN_OPTIONS]
+# Resets the guest fixture with that daemon, runs every probe mode, verifies
+# the synthetic secret after each mode, and prints a summary. Output goes to
+# stdout; the caller redirects it to a log. Exit 1 on any failure.
+set -uo pipefail
+label="$1"; daemon="$2"; asan="${3:-}"
+here="$(cd "$(dirname "$0")" && pwd)"
+PORT="${PORT:-2237}"
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p "$PORT" root@127.0.0.1 "$@"; }
+gcp() { sshpass -p archsetup scp "${ssho[@]}" -P "$PORT" "$@"; }
+as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus'
+fail=0
+echo "=== $label: daemon=$daemon asan=${asan:-none} $(date -Is)"
+gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null
+gcp "$here/setup-guest.sh" root@127.0.0.1:/root/setup-guest.sh >/dev/null
+g "chmod +x /root/setup-guest.sh; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" || { echo "FIXTURE FAILED"; exit 1; }
+since=$(g 'date "+%Y-%m-%d %H:%M:%S"')
+owner0=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ')
+echo "owner before: $owner0"
+run_mode() { # mode clients workers burst
+ local out
+ out=$(g "$as_user python3 /opt/keyring-vm/probe.py $1 --clients $2 --workers $3 --burst $4 --batch 100" 2>&1)
+ local rc=$?
+ echo "$out" | tail -3
+ [ $rc -eq 0 ] || { echo "MODE $1 FAILED (rc=$rc)"; fail=1; }
+ local secret
+ secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null)
+ if [ "$secret" = "synthetic-vm-secret" ]; then echo "secret after $1: ok"; else echo "secret after $1: FAILED (${secret:-empty})"; fail=1; fi
+}
+run_mode plain 4000 32 8
+run_mode dh 4000 32 8
+run_mode invalid 4000 32 8
+run_mode disconnect 4000 32 8
+run_mode pipeline 4000 32 8
+run_mode alias 2000 32 1
+run_mode isolation 2000 32 1
+owner1=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ')
+echo "owner after: $owner1"
+[ "$owner0" = "$owner1" ] || { echo "OWNER CHANGED"; fail=1; }
+echo "--- daemon journal since $since (assertions/aborts/criticals)"
+g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring|secrets|asan|AddressSanitizer' | grep -iE 'assert|abort|SIGABRT|critical|sanitizer|ERROR|dumped' | head -40"
+if [ -n "$asan" ]; then echo "--- asan logs"; g 'ls /tmp/asan.log* 2>/dev/null && cat /tmp/asan.log* | head -80 || echo "no asan log files"'; fi
+echo "=== $label result: $([ $fail -eq 0 ] && echo PASS || echo FAIL) $(date -Is)"
+exit $fail
diff --git a/working/keyring-crash/run-upstream-tests.sh b/working/keyring-crash/run-upstream-tests.sh
new file mode 100755
index 0000000..055dc7b
--- /dev/null
+++ b/working/keyring-crash/run-upstream-tests.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Upstream meson test suites, stock vs patched, identical options, each in its
+# own private session bus. Prints the summary lines and the failing test names.
+set -uo pipefail
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; }
+g 'set -e; cd /root; if [ ! -d build-stock-cmp ]; then rm -rf src-stock-cmp; mkdir src-stock-cmp; tar -xJf gnome-keyring-51.1.tar.xz -C src-stock-cmp; meson setup build-stock-cmp src-stock-cmp/gnome-keyring-51.1 -Dbuildtype=debugoptimized -Dprefix=/usr -Dlibdir=lib -Ddebug-mode=false -Dmanpage=false >/tmp/stock-cmp-setup.log 2>&1; ninja -C build-stock-cmp >/tmp/stock-cmp-build.log 2>&1; fi
+set +e; for b in build-stock-cmp build-v6; do echo "=== $b"; dbus-run-session -- meson test -C $b --no-rebuild --print-errorlogs >/tmp/mtest-$b.log 2>&1; grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/mtest-$b.log | tr "\n" " "; echo; grep -E "(FAIL|TIMEOUT) " /tmp/mtest-$b.log | awk "{print \$2, \$3, \$4, \$5}" | sort -u; done'
diff --git a/working/keyring-crash/setup-guest.sh b/working/keyring-crash/setup-guest.sh
new file mode 100644
index 0000000..0da7c69
--- /dev/null
+++ b/working/keyring-crash/setup-guest.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Exploration fixture: run as root only in the disposable VM.
+set -euo pipefail
+[[ $(uname -n) == archsetup-test && $EUID == 0 ]] || exit 2
+if ! id keyring-test >/dev/null 2>&1; then
+ useradd -m -u 1000 -s /bin/bash keyring-test
+ printf '%s\n' 'keyring-test:synthetic-vm-password' | chpasswd
+fi
+loginctl enable-linger keyring-test
+systemctl start user@1000.service
+runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ systemctl --user stop gnome-keyring-daemon.service gnome-keyring-daemon.socket
+pkill -KILL -u 1000 -x gnome-keyring-d || true
+runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ systemctl --user enable gnome-keyring-daemon.socket
+printf '%s\n' 'synthetic-vm-password' | runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ LD_LIBRARY_PATH="${KEYRING_DAEMON_LIBRARY_PATH:-}" \
+ ASAN_OPTIONS="${ASAN_OPTIONS:-}" \
+ "${KEYRING_DAEMON_BINARY:-gnome-keyring-daemon}" --unlock
+printf '%s\n' 'synthetic-vm-secret' | runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ secret-tool store --label='Disposable VM fixture' purpose keyring-vm
+observed=$(runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ secret-tool lookup purpose keyring-vm)
+[[ $observed == synthetic-vm-secret ]]
+echo 'Synthetic encrypted-secret round trip passed.'
+pacman -Q gnome-keyring glib2 libsecret dbus dbus-broker dbus-broker-units systemd
+runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ systemctl --user show gnome-keyring-daemon.service -p MainPID -p NRestarts
diff --git a/working/keyring-crash/status.org b/working/keyring-crash/status.org
new file mode 100644
index 0000000..aa0a952
--- /dev/null
+++ b/working/keyring-crash/status.org
@@ -0,0 +1,118 @@
+#+TITLE: Keyring crash: cause, fix, validation, deployment
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-10-17
+#+STARTUP: showall
+
+Supersedes the status in the main checkout's status-and-plan.org from 10-16.
+Everything below was done in a disposable VM and this worktree; the host
+keyring, its packages and services were untouched until the deployment step.
+
+* Cause
+
+gnome-keyring aborts when a Secret Service method call is dispatched before
+the daemon has created the caller's per-client record. The record is created
+by a G_PRIORITY_HIGH idle queued from a GDBus message filter on the worker
+thread; that idle is not reliably ordered against the method dispatch, so
+under concurrent bursts a call occasionally arrives first, get_pkcs11_session
+asserts, the negotiate helper returns FALSE without a GError, OpenSession
+completes with a NULL body, and g_variant_new aborts the daemon. systemd
+restarts it, the unlocked state is gone, and the next request shows the
+keyring password dialog.
+
+This is upstream bug 190 (also 194, 195, 196, 199), present in 48 through
+51.1, unmerged fix in merge request !112 since 2026-08-25. On ratio the first
+abort was 2026-10-11 19:03:59, the evening the ChatGPT desktop app was
+installed; openai/codex issue 51694 documents its bundled app-server opening
+a fresh Secret Service connection per keyring read in bursts and crashing
+gnome-keyring with exactly this signature. The same-day glib and keyring
+upgrades were a coincidence: the VM reproduced the crash on 50.0 as well.
+
+A second, pre-existing defect surfaced while testing the fix: the filter's
+alias rewrite reads the alias table on the worker thread while the main
+thread replaces and frees its entries on every client registration.
+AddressSanitizer confirmed it as a heap-use-after-free read in
+gkd_secret_util_build_path from rewrite_default_alias, freed by
+g_hash_table_replace in update_default. Stock and !112 both carry it.
+
+* Fix (packages/gnome-keyring, patch 0002)
+
+- Register the client at the point of use in get_pkcs11_session and
+ publish_dispatch (as !112).
+- aes_negotiate and plain_negotiate return G_DBUS_ERROR_FAILED instead of
+ asserting; OpenSession branches on the return value and never completes
+ with a NULL body (as !112).
+- lookup_collection takes the caller's PKCS#11 session before resolving an
+ alias, so the lazy registration cannot free a borrowed alias string (the
+ earlier reviewer's use-after-free finding).
+- The property-path lookup returns NO_SUCH_OBJECT instead of asserting when
+ there is no session (upstream 195).
+- A mutex guards the alias table; the worker-thread filter and store_default
+ take owned copies under it; the default alias is replaced only when its
+ value changes so main-thread borrowers stay valid.
+
+Packaged as Arch's own recipe for 1:51.1-1 (Cinnamon patch kept, arch-meson,
+selinux disabled) built from GNOME's release tarball (published sha256
+dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca) plus the
+patch, at pkgrel 1.1: above stock, below any future 1:51.1-2 or 51.2, so a
+normal upgrade replaces it when a real fix lands. Nothing pins or holds it.
+
+* Validation
+
+Probe (working/keyring-crash/probe.py): real GIO clients, seven modes, every
+reply validated, a daemon round trip after each batch of 100, the synthetic
+secret read back after each mode, bus owner and RSS recorded. Counts: 4000
+clients at 32 workers with bursts of 8 for plain, dh, invalid, disconnect and
+pipeline; 2000 for alias and isolation; alias also at 8000.
+
+| Daemon | Result |
+|-----------------------------+-----------------------------------------------------|
+| stock 51.1 | aborts on the first plain batch, collection locked, |
+| | ratio's exact journal signature (three runs) |
+|-----------------------------+-----------------------------------------------------|
+| v4 (lazy registration only) | passes all modes; alias at 8000 returns 9 bad paths |
+| | and the filter asserts on a malformed object path |
+|-----------------------------+-----------------------------------------------------|
+| v4 under ASan | heap-use-after-free in rewrite_default_alias |
+|-----------------------------+-----------------------------------------------------|
+| v5 and v6, normal and ASan | all modes pass, alias at 8000 passes, no sanitizer |
+| | report, stable owner, RSS flat, secret intact |
+|-----------------------------+-----------------------------------------------------|
+| v6 package via pacman -U | all modes pass through the installed /usr/bin daemon|
+|-----------------------------+-----------------------------------------------------|
+
+Independent review of v5 (patch, pristine and patched trees, prior reviewer's
+requirements, no rationale): approve, no memory-safety defect after checking
+every caller. Two minor notes taken in v6 (store_default under the lock, plain
+NULL return); two declined as pre-existing and bounded (a late dispatch after
+NameOwnerChanged leaks one PKCS#11 session per vanished peer, unique names
+never recur; the remove_filter shutdown race).
+
+Upstream meson suites, identical options and a private bus, run back to back:
+stock and v6 both 56 ok, 1 fail, 5 timeouts, the same names in both
+(gnome2-store import fails intermittently; stock scored 57 ok and 0 fail on
+its first run; the five dbus fixture tests time out identically). No
+regression attributable to the patch. Arch's own recipe ignores this suite's
+failures, which is why it is a comparison here and not a gate.
+
+Installer: install_gnome_keyring_fix runs after pacman_install gnome-keyring,
+builds and installs the recipe with makepkg as the user when stock 51.1 (or
+older) is installed, is a no-op on the rebuild, and skips with a visible
+warning when something newer is installed. Eleven tests in
+tests/installer-steps/test_install_gnome_keyring_fix.py; the fast suite (619
+Python plus 17 ERT tests) passes. The false autologin comment on the
+keyring-dir block is corrected.
+
+* Deployment
+
+working/keyring-crash/deploy-ratio.sh: build (done on ratio 06:53, package at
+~/.cache/archsetup-build/gnome-keyring/), install (permission-protected copy
+of ~/.local/share/keyrings, pacman -U, restart only
+gnome-keyring-daemon.service, verify), rollback (pacman -U of the cached stock
+1:51.1-1, restart), verify. One unlock prompt is expected after the restart;
+answer it in the system dialog. The same script applies to velox once it is
+online, after confirming its versions match.
+
+Observation period: the stock daemon aborted about three times a day on
+ratio. A restart counter that stays flat for several days of normal ChatGPT
+and Claude desktop use is the evidence of lasting stability; a short check
+after install is not.
diff --git a/working/keyring-crash/vm-run.sh b/working/keyring-crash/vm-run.sh
new file mode 100755
index 0000000..482ab3d
--- /dev/null
+++ b/working/keyring-crash/vm-run.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Boot the retained disposable keyring guest headlessly. SSH is forwarded on
+# loopback only. Runtime files (pid, monitor socket, serial log) live under
+# the caller's RUN dir; the overlay and firmware file are the retained ones.
+set -euo pipefail
+OVERLAY="${OVERLAY:?path to guest.qcow2}"
+VARS="${VARS:?path to OVMF_VARS.fd}"
+RUN="${RUN:?runtime dir}"
+PORT="${PORT:-2237}"
+mkdir -p "$RUN"
+exec qemu-system-x86_64 -name keyring-vm -machine q35,accel=kvm -cpu host \
+ -smp 2 -m 4096 \
+ -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
+ -drive "if=pflash,format=raw,file=$VARS" \
+ -drive "file=$OVERLAY,format=qcow2,if=virtio" \
+ -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${PORT}-:22" -device virtio-net-pci,netdev=net0 \
+ -monitor "unix:$RUN/monitor.sock,server,nowait" -pidfile "$RUN/qemu.pid" \
+ -serial "file:$RUN/serial.log" -display none -boot c