aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-10-17 07:07:25 -0500
committerCraig Jennings <c@cjennings.net>2026-10-17 07:07:25 -0500
commitcd6e11b46ecf2b56bf63d6c11886dccc70875e10 (patch)
treee2d17ef968ae24ef1b65babaadfc7637cc2024bb
parente530fd597b62bda520f9804be7da216eea582750 (diff)
downloadarchsetup-cd6e11b46ecf2b56bf63d6c11886dccc70875e10.tar.gz
archsetup-cd6e11b46ecf2b56bf63d6c11886dccc70875e10.zip
feat(keyring): ship a patched gnome-keyring and install it from the repoclaude/hungry-franklin-09fffc
gnome-keyring 48 through 51.1 aborts when a client's first OpenSession is dispatched before the daemon has created its per-client record. Each abort drops the unlocked state and the next request pops the password dialog. On ratio that happened about three times a day from 2026-10-11, when the ChatGPT desktop app's codex app-server started bursting keyring reads. The same-day glib and keyring upgrades were a coincidence, since 50.0 reproduces it too. Upstream's fix (merge request !112) has sat unmerged since August. packages/gnome-keyring is Arch's recipe for 1:51.1-1 built from GNOME's tarball plus one patch. It registers the client at the point of use and returns a real D-Bus error instead of completing OpenSession with a NULL body. It resolves a collection alias only after the caller's session exists. It also locks the alias table so the worker-thread filter can't read an entry the main thread is freeing, a pre-existing defect that AddressSanitizer confirmed. I chose pkgrel 1.1 so stock sorts below the rebuild and any future Arch or upstream fix replaces it on a normal upgrade. Nothing pins the package. install_gnome_keyring_fix replaces the plain stock install, which pacman's --needed would otherwise use to downgrade the rebuild on every re-run. It installs stock only when no gnome-keyring is present, compares versions with vercmp, then stages the recipe into the source dir and builds it with makepkg as the user. The rebuild is a no-op, anything newer skips with a visible note, and a failed build leaves stock in place. I also corrected the keyring-dir comment: the default alias only picks the collection and doesn't unlock anything under autologin. working/keyring-crash holds the probe, the guest fixture and runners, and the deployment script with backup and rollback. status.org there records the cause and results. Stock aborts on the probe's first batch. The patched daemon, its sanitizer build and the installed package pass every mode, and the upstream suite scores the same for both.
-rwxr-xr-xarchsetup69
-rw-r--r--packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch51
-rw-r--r--packages/gnome-keyring/0002-secret-service-missing-client.patch275
-rw-r--r--packages/gnome-keyring/PKGBUILD82
-rw-r--r--packages/gnome-keyring/gnome-keyring.install14
-rw-r--r--tests/installer-steps/test_install_gnome_keyring_fix.py209
-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
16 files changed, 1621 insertions, 3 deletions
diff --git a/archsetup b/archsetup
index 26157b3..f5efb26 100755
--- a/archsetup
+++ b/archsetup
@@ -1719,6 +1719,65 @@ aur_installer() {
error_fatal "$action" "$?" "confirm base-devel is installed (pacman -S base-devel), then re-run"
}
+### GNOME Keyring Secret Service fix
+# gnome-keyring 48 through 51.1 aborts when a client's first OpenSession is
+# dispatched before the daemon has created its per-client record (GNOME issue
+# 190, merge request !112, unmerged as of 2026-10-17). Each abort drops the
+# unlocked state, so the next request pops the keyring password dialog; on
+# ratio that fired about three times a day once the ChatGPT desktop app's
+# codex app-server started bursting keyring reads. Until Arch or upstream
+# ships the fix, packages/gnome-keyring in this repo is Arch's own recipe for
+# 1:51.1-1 plus the patch, at pkgrel 1.1. That version sorts above stock and
+# below any future 1:51.1-2 or 51.2, so a normal upgrade replaces it the
+# moment a real fix lands; nothing here pins or holds the package.
+#
+# The build runs as the user from a copy of the recipe under the source dir:
+# makepkg refuses to run as root and writes build products next to the
+# PKGBUILD, which must not land in the user's checkout. A failed build warns
+# and leaves the stock package in place rather than installing anything by
+# hand.
+#
+# This step owns the stock install too. pacman's --needed only skips an equal
+# version, so a plain pacman_install gnome-keyring ahead of this step would
+# downgrade the rebuild back to stock on every re-run and trigger a fresh
+# build and daemon restart each time. Stock is installed only when no
+# gnome-keyring is present at all, as the baseline the rebuild upgrades.
+install_gnome_keyring_fix() {
+ local recipe="${user_archsetup_dir:-/home/$username/code/archsetup}/packages/gnome-keyring"
+ local build_dir="$source_dir/gnome-keyring"
+ local fixed_version="1:51.1-1.1"
+ local installed
+
+ action="patched gnome-keyring (Secret Service missing-client fix)" && display "task" "$action"
+
+ installed="$(pacman -Q gnome-keyring 2>/dev/null | awk '{print $2}')"
+ if [ -z "$installed" ]; then
+ pacman_install gnome-keyring
+ else
+ case "$(vercmp "$installed" "$fixed_version")" in
+ 0) return 0 ;; # the rebuild is already installed
+ 1) # A deliberate skip, not an error: something newer than the
+ # rebuild is installed, so the fix is assumed to be upstream.
+ display "task" "gnome-keyring $installed is newer than the rebuild, skipping; verify the Secret Service no longer aborts"
+ return 0 ;;
+ esac
+ fi
+
+ [ -f "$recipe/PKGBUILD" ] || { error_warn "$action: recipe missing at $recipe" 1; return 1; }
+
+ if [ -e "$build_dir" ]; then
+ safe_rm_rf "$build_dir" "$source_dir" >> "$logfile" 2>&1 || \
+ { error_warn "$action: removing stale build dir $build_dir" "$?"; return 1; }
+ fi
+ (mkdir -p "$build_dir" && cp -a "$recipe/." "$build_dir/" && chown -R "$username": "$build_dir") >> "$logfile" 2>&1 || \
+ { error_warn "$action: staging the recipe" "$?"; return 1; }
+
+ # -s installs the makedepends, -i installs the result; one command, as the
+ # yay bootstrap does, so a build failure is the install failure.
+ (cd "$build_dir" && sudo -u "$username" makepkg --noconfirm -si >> "$logfile" 2>&1) || \
+ { error_warn "$action: makepkg failed, stock gnome-keyring left in place" "$?"; return 1; }
+}
+
### Essential Services
essential_services() {
display "title" "Essential Services"
@@ -2748,7 +2807,7 @@ desktop_environment() {
action="Authentication Tools" && display "subtitle" "$action"
pacman_install gnupg
pacman_install polkit
- pacman_install gnome-keyring
+ install_gnome_keyring_fix # installs stock gnome-keyring first when none is present
pacman_install seahorse
pacman_install pass
pacman_install rbw # Bitwarden CLI client (Rust)
@@ -2760,8 +2819,12 @@ desktop_environment() {
find /home/"$username"/.gnupg -type f -exec chmod 600 {} \;
find /home/"$username"/.gnupg -type d -exec chmod 700 {} \;
- # pre-create gnome-keyring directory and set default keyring to "login"
- # this allows auto-unlock without password prompt (works with autologin)
+ # Pre-create the gnome-keyring directory and name "login" as the default
+ # collection. That only picks which collection applications use; the
+ # unlock itself comes from pam_gnome_keyring in the login and hyprlock PAM
+ # stacks, which unlock the login collection with the password typed at
+ # login. It does not unlock anything under console autologin, where no
+ # password is typed (I had claimed otherwise here; corrected 2026-10-17).
keyring_dir="/home/$username/.local/share/keyrings"
mkdir -p "$keyring_dir"
echo "login" > "$keyring_dir/default"
diff --git a/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch b/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch
new file mode 100644
index 0000000..980d537
--- /dev/null
+++ b/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch
@@ -0,0 +1,51 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Jan Alexander Steffens (heftig)" <heftig@archlinux.org>
+Date: Sat, 21 May 2022 18:47:23 +0000
+Subject: [PATCH] daemon: Add Cinnamon to autostart files
+
+Cinnamon also wants to use gnome-keyring-daemon.
+---
+ daemon/gnome-keyring-pkcs11.desktop.in.in | 2 +-
+ daemon/gnome-keyring-secrets.desktop.in.in | 2 +-
+ daemon/gnome-keyring-ssh.desktop.in.in | 2 +-
+ 3 files changed, 3 insertions(+), 3 deletions(-)
+
+diff --git a/daemon/gnome-keyring-pkcs11.desktop.in.in b/daemon/gnome-keyring-pkcs11.desktop.in.in
+index 3e36603fac70..bb729caddfd1 100644
+--- a/daemon/gnome-keyring-pkcs11.desktop.in.in
++++ b/daemon/gnome-keyring-pkcs11.desktop.in.in
+@@ -3,7 +3,7 @@ Type=Application
+ Name=Certificate and Key Storage
+ Comment=GNOME Keyring: PKCS#11 Component
+ Exec=@bindir@/gnome-keyring-daemon --start --components=pkcs11
+-OnlyShowIn=GNOME;Unity;MATE;
++OnlyShowIn=GNOME;Unity;MATE;Cinnamon;
+ NoDisplay=true
+ X-GNOME-Autostart-Phase=PreDisplayServer
+ X-GNOME-AutoRestart=false
+diff --git a/daemon/gnome-keyring-secrets.desktop.in.in b/daemon/gnome-keyring-secrets.desktop.in.in
+index 799caeea4306..6c836a2a7ec9 100644
+--- a/daemon/gnome-keyring-secrets.desktop.in.in
++++ b/daemon/gnome-keyring-secrets.desktop.in.in
+@@ -3,7 +3,7 @@ Type=Application
+ Name=Secret Storage Service
+ Comment=GNOME Keyring: Secret Service
+ Exec=@bindir@/gnome-keyring-daemon --start --components=secrets
+-OnlyShowIn=GNOME;Unity;MATE;
++OnlyShowIn=GNOME;Unity;MATE;Cinnamon;
+ NoDisplay=true
+ X-GNOME-Autostart-Phase=PreDisplayServer
+ X-GNOME-AutoRestart=false
+diff --git a/daemon/gnome-keyring-ssh.desktop.in.in b/daemon/gnome-keyring-ssh.desktop.in.in
+index fd7657a4d0cc..8f9847033507 100644
+--- a/daemon/gnome-keyring-ssh.desktop.in.in
++++ b/daemon/gnome-keyring-ssh.desktop.in.in
+@@ -3,7 +3,7 @@ Type=Application
+ Name=SSH Key Agent
+ Comment=GNOME Keyring: SSH Agent
+ Exec=@bindir@/gnome-keyring-daemon --start --components=ssh
+-OnlyShowIn=GNOME;Unity;MATE;
++OnlyShowIn=GNOME;Unity;MATE;Cinnamon;
+ X-GNOME-Autostart-Phase=PreDisplayServer
+ X-GNOME-AutoRestart=false
+ X-GNOME-Autostart-Notify=true
diff --git a/packages/gnome-keyring/0002-secret-service-missing-client.patch b/packages/gnome-keyring/0002-secret-service-missing-client.patch
new file mode 100644
index 0000000..9ef298b
--- /dev/null
+++ b/packages/gnome-keyring/0002-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/packages/gnome-keyring/PKGBUILD b/packages/gnome-keyring/PKGBUILD
new file mode 100644
index 0000000..a8e32a2
--- /dev/null
+++ b/packages/gnome-keyring/PKGBUILD
@@ -0,0 +1,82 @@
+# Local rebuild of Arch's gnome-keyring 1:51.1-1 with the Secret Service
+# missing-client fix applied. I build from GNOME's release tarball (checksum
+# published by GNOME) instead of the signed git tag so the build does not need
+# the maintainer's key in the keyring of whichever machine builds it. pkgrel
+# 1.1 sorts above the stock 1:51.1-1 and below any future 1:51.1-2, so the
+# next upstream or Arch fix replaces it on a normal upgrade.
+# Maintainer: Jan Alexander Steffens (heftig) <heftig@archlinux.org>
+# Contributor: Jan De Groot <jgc@archlinux.org>
+
+pkgname=gnome-keyring
+pkgver=51.1
+pkgrel=1.1
+epoch=1
+pkgdesc="Stores passwords and encryption keys (Secret Service missing-client fix)"
+url="https://gitlab.gnome.org/GNOME/gnome-keyring"
+arch=(x86_64)
+license=(
+ GPL-2.0-or-later
+ LGPL-2.1-or-later
+)
+depends=(
+ dconf
+ gcr
+ gcr-4
+ glib2
+ glibc
+ libcap-ng
+ libgcc
+ libgcrypt
+ pam
+ systemd-libs
+)
+makedepends=(
+ docbook-xsl
+ git
+ glib2-devel
+ meson
+ p11-kit
+ python
+)
+provides=(org.freedesktop.secrets)
+groups=(gnome)
+install=gnome-keyring.install
+source=(
+ "https://download.gnome.org/sources/gnome-keyring/${pkgver%%.*}/gnome-keyring-$pkgver.tar.xz"
+ 0001-daemon-Add-Cinnamon-to-autostart-files.patch
+ 0002-secret-service-missing-client.patch
+)
+sha256sums=('dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca'
+ '138ad9a41dcbf9faede3a8b7fcb61dd5894ca8b66599802e58f8bb81e9607eb2'
+ '2463bf109af415412bf99e9aca98c51f215cfdcc9de728bbf7155db861bf79d7')
+
+prepare() {
+ cd gnome-keyring-$pkgver
+
+ # Autolaunch in Cinnamon (Arch's own patch, unchanged)
+ patch -Np1 -i ../0001-daemon-Add-Cinnamon-to-autostart-files.patch
+ # Register Secret Service clients at the point of use, return real errors
+ # instead of completing OpenSession with a NULL body, and resolve the
+ # collection alias only after the caller's session exists.
+ patch -Np1 -i ../0002-secret-service-missing-client.patch
+}
+
+build() {
+ local meson_options=(
+ -D selinux=disabled
+ )
+
+ arch-meson gnome-keyring-$pkgver build "${meson_options[@]}"
+ meson compile -C build
+}
+
+check() {
+ # Secure memory tests fail
+ dbus-run-session meson test -C build --print-errorlogs || :
+}
+
+package() {
+ meson install -C build --no-rebuild --destdir "$pkgdir"
+}
+
+# vim:set sw=2 sts=-1 et:
diff --git a/packages/gnome-keyring/gnome-keyring.install b/packages/gnome-keyring/gnome-keyring.install
new file mode 100644
index 0000000..6143dd1
--- /dev/null
+++ b/packages/gnome-keyring/gnome-keyring.install
@@ -0,0 +1,14 @@
+post_install() {
+ # Enable socket by default
+ systemctl --global enable gnome-keyring-daemon.socket
+}
+
+post_upgrade() {
+ if (( $(vercmp $2 1:42.0-1) < 0)); then
+ systemctl --global enable gnome-keyring-daemon.socket
+ fi
+}
+
+pre_remove() {
+ systemctl --global disable gnome-keyring-daemon.socket
+}
diff --git a/tests/installer-steps/test_install_gnome_keyring_fix.py b/tests/installer-steps/test_install_gnome_keyring_fix.py
new file mode 100644
index 0000000..1a4115a
--- /dev/null
+++ b/tests/installer-steps/test_install_gnome_keyring_fix.py
@@ -0,0 +1,209 @@
+"""Tests for install_gnome_keyring_fix in the archsetup installer.
+
+gnome-keyring 51.1 (and 48 through 50) aborts when a Secret Service client's
+first OpenSession is dispatched before the daemon has created its per-client
+record. Every abort loses the unlocked state, so the next request pops the
+keyring password dialog; on ratio this fired three times a day once the
+ChatGPT desktop app's codex app-server started bursting keyring reads. The
+fix is upstream merge request !112 plus an alias-lifetime correction, carried
+as a patched rebuild of Arch's own package under packages/gnome-keyring in
+this repo (pkgrel 1.1). install_gnome_keyring_fix builds and installs it with
+makepkg as the user when the stock 1:51.1-1 is what is installed.
+
+Versioning is the whole contract. The rebuild sorts above stock 1:51.1-1 and
+below any future 1:51.1-2 or 51.2, so a normal pacman upgrade replaces it the
+moment Arch or upstream ships a fix, and the step must never pin or hold the
+package. When something newer than the rebuild is already installed, the fix
+is assumed upstream and the step skips with a warning so the assumption is
+visible rather than silent. When the rebuild itself is installed, the step is
+a no-op.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time. pacman, vercmp, makepkg and sudo are replaced by
+recorders driven by environment switches; the package directory is a temp
+copy of the real packages/gnome-keyring tree.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_gnome_keyring_fix
+"""
+
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+PACKAGE_DIR = os.path.join(REPO_ROOT, "packages", "gnome-keyring")
+
+STUBS = r"""
+logfile=/dev/null
+action=""
+username=tester
+display() { echo "DISPLAY: $*"; }
+pacman_install() { echo "PACMAN_INSTALL: $1" >> "$MARKER"; }
+error_warn() { echo "WARN:$1"; return 1; }
+# pacman -Q prints the installed version, or fails when INSTALLED is empty.
+pacman() {
+ echo "PACMAN: $*" >> "$MARKER"
+ case "$1" in
+ -Q) [ -n "$INSTALLED" ] && echo "gnome-keyring $INSTALLED" ;;
+ *) return 0 ;;
+ esac
+}
+# The real vercmp is used so the ordering under test is pacman's own.
+makepkg() {
+ echo "MAKEPKG: $* (pwd=$PWD)" >> "$MARKER"
+ [ "$MAKEPKG_FAILS" = 1 ] && return 2
+ return 0
+}
+# sudo -u user cmd... runs cmd in place; the recorder notes the user.
+sudo() {
+ echo "SUDO: $*" >> "$MARKER"
+ shift 2
+ "$@"
+}
+safe_rm_rf() { rm -rf "$1"; }
+# chown needs root; the tests only care that the copy was staged.
+chown() { echo "CHOWN: $*" >> "$MARKER"; }
+"""
+
+
+def run(pkg_dir, installed, makepkg_fails=False, source_dir=None):
+ marker = tempfile.NamedTemporaryFile(prefix="keyring-fix-", delete=False)
+ marker.close()
+ source_dir = source_dir or tempfile.mkdtemp(prefix="keyring-src-")
+ script = (
+ STUBS
+ + "source <(sed -n '/^install_gnome_keyring_fix() {/,/^}/p' \"%s\")\n"
+ + 'user_archsetup_dir="%s"\n'
+ + 'source_dir="%s"\n'
+ + "install_gnome_keyring_fix\n"
+ + 'echo "RC=$?"\n'
+ ) % (ARCHSETUP, pkg_dir, source_dir)
+ env = dict(os.environ, MARKER=marker.name, INSTALLED=installed,
+ MAKEPKG_FAILS="1" if makepkg_fails else "0")
+ result = subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=30, env=env)
+ with open(marker.name) as f:
+ calls = f.read().splitlines()
+ os.remove(marker.name)
+ return result, calls, source_dir
+
+
+class InstallGnomeKeyringFix(unittest.TestCase):
+ def setUp(self):
+ # A fake archsetup checkout holding a copy of the real package tree.
+ self.checkout = tempfile.mkdtemp(prefix="archsetup-checkout-")
+ self.addCleanup(shutil.rmtree, self.checkout, ignore_errors=True)
+ shutil.copytree(PACKAGE_DIR, os.path.join(self.checkout, "packages", "gnome-keyring"))
+ self.source_dir = tempfile.mkdtemp(prefix="keyring-src-")
+ self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True)
+
+ def _run(self, installed, **kw):
+ return run(self.checkout, installed, source_dir=self.source_dir, **kw)
+
+ @staticmethod
+ def _makepkg_calls(calls):
+ return [c for c in calls if c.startswith("MAKEPKG:")]
+
+ # ---------------------------------------------------------- normal ----
+ def test_stock_51_1_gets_the_rebuild(self):
+ result, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1, calls)
+ self.assertIn("RC=0", result.stdout)
+ self.assertNotIn("WARN:", result.stdout)
+
+ def test_builds_as_the_user_with_syncdeps_and_install(self):
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertTrue(any(c.startswith("SUDO: -u tester makepkg") for c in calls), calls)
+ makepkg = self._makepkg_calls(calls)[0]
+ self.assertIn("--noconfirm", makepkg)
+ # -s (sync makedepends) and -i (install), spelled separately or combined.
+ short = "".join(t[1:] for t in makepkg.split() if re.fullmatch(r"-[a-zA-Z]+", t))
+ self.assertIn("s", short, makepkg)
+ self.assertIn("i", short, makepkg)
+
+ def test_builds_from_a_user_owned_copy_not_the_checkout(self):
+ # makepkg writes into its directory; the checkout must stay clean.
+ _, calls, _ = self._run("1:51.1-1")
+ makepkg = self._makepkg_calls(calls)[0]
+ self.assertIn("pwd=" + self.source_dir, makepkg)
+ self.assertNotIn("pwd=" + self.checkout, makepkg)
+ self.assertTrue(os.path.exists(os.path.join(self.source_dir, "gnome-keyring", "PKGBUILD")))
+
+ def test_older_stock_releases_also_get_the_rebuild(self):
+ # 50.0 carries the same bug; the rebuild is 51.1 so it is an upgrade.
+ _, calls, _ = self._run("1:50.0-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+
+ # -------------------------------------------------------- boundary ----
+ def test_rebuild_already_installed_is_a_noop(self):
+ result, calls, _ = self._run("1:51.1-1.1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+ self.assertIn("RC=0", result.stdout)
+ self.assertNotIn("WARN:", result.stdout)
+
+ def test_newer_package_skips_visibly_without_counting_an_error(self):
+ for newer in ("1:51.1-2", "1:51.2-1", "1:52.0-1"):
+ with self.subTest(installed=newer):
+ result, calls, _ = self._run(newer)
+ self.assertEqual(self._makepkg_calls(calls), [], newer)
+ self.assertIn("newer than the rebuild", result.stdout,
+ "a newer package means the fix is assumed upstream; say so")
+ self.assertNotIn("WARN:", result.stdout,
+ "a deliberate skip must not count as an installer error")
+ self.assertIn("RC=0", result.stdout)
+
+ def test_rerun_after_success_does_not_rebuild(self):
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+ _, calls, _ = self._run("1:51.1-1.1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+
+ def test_missing_package_installs_stock_first_then_builds(self):
+ # With no gnome-keyring present, stock is installed as the baseline
+ # and the rebuild then upgrades it; both happen in that order.
+ result, calls, _ = self._run("")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+ stock = [i for i, c in enumerate(calls) if c.startswith("PACMAN_INSTALL: gnome-keyring")]
+ build = [i for i, c in enumerate(calls) if c.startswith("MAKEPKG:")]
+ self.assertEqual(len(stock), 1, calls)
+ self.assertLess(stock[0], build[0], "stock install must precede the build")
+ self.assertIn("RC=0", result.stdout)
+
+ def test_rerun_with_rebuild_installed_never_touches_stock(self):
+ # pacman's --needed only skips an equal version, so a stock install
+ # ahead of the rebuild would downgrade it on every re-run. The step
+ # must not call the stock install at all once anything is installed.
+ for installed in ("1:51.1-1.1", "1:51.1-1", "1:51.2-1"):
+ with self.subTest(installed=installed):
+ _, calls, _ = self._run(installed)
+ self.assertFalse(any(c.startswith("PACMAN_INSTALL:") for c in calls), calls)
+
+ # ----------------------------------------------------------- error ----
+ def test_failed_build_warns_and_leaves_stock_in_place(self):
+ result, calls, _ = self._run("1:51.1-1", makepkg_fails=True)
+ self.assertIn("WARN:", result.stdout)
+ self.assertNotIn("RC=0", result.stdout)
+ self.assertFalse(any(c.startswith("PACMAN: -U") for c in calls),
+ "nothing may be installed by hand after a failed build")
+
+ def test_missing_recipe_warns_instead_of_building(self):
+ shutil.rmtree(os.path.join(self.checkout, "packages", "gnome-keyring"))
+ result, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+ self.assertIn("WARN:", result.stdout)
+
+ def test_never_pins_or_holds_the_package(self):
+ # No IgnorePkg edit and no pacman -D hold: the next upstream fix must
+ # replace the rebuild on a normal upgrade.
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertFalse(any("IgnorePkg" in c for c in calls))
+ self.assertFalse(any(c.startswith("PACMAN: -D") for c in calls))
+
+
+if __name__ == "__main__":
+ unittest.main()
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