Skip to content

fix(pi-extension): timeout hanging keyring reads/writes on Linux - #78

Open
andrea-tomassi wants to merge 2 commits into
jacobaraujo7:mainfrom
andrea-tomassi:fix/keyring-hang-timeout
Open

fix(pi-extension): timeout hanging keyring reads/writes on Linux#78
andrea-tomassi wants to merge 2 commits into
jacobaraujo7:mainfrom
andrea-tomassi:fix/keyring-hang-timeout

Conversation

@andrea-tomassi

Copy link
Copy Markdown

Problem

On Linux, running /remote-pi pair inside tmux / a non-interactive / headless session freezes completely — the panel locks up and has to be killed. No QR is ever rendered.

Root cause

getOrCreateEd25519Keypair() calls into the platform keyring via @napi-rs/keyring (libsecret → gnome-keyring). On Linux, gnome-keyring can block waiting on a GUI authorization prompt ("Allow this application to access the keyring?") that cannot be shown from inside tmux/headless contexts.

Crucially, the native getPassword() / setPassword() call hangs indefinitely — the promise never settles and never rejects. The existing error handling in getOrCreateEd25519Keypair() only catches thrown errors:

Path 0: read identity.json file      ← runs first
Path A: keyring read (retried 3×)    ← HANGS HERE, never throws
Path B: fallback to file identity    ← unreachable

Because the hang never produces an error, the retry loop and the Path B file-fallback (which would otherwise mint a file-backed identity and recover) are never reached. The whole /remote-pi pair bootstrap blocks forever.

Fix

Wrap each native keyring operation in a 3-second Promise.race timeout at the NapiKeyringBackend level:

function _withTimeout<T>(p: Promise<T>, op: string, ms = 3_000): Promise<T> {
  return Promise.race([
    p,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error(`keyring ${op} timed out after ${ms}ms`)), ms)),
  ]);
}

A hang now becomes a thrown error, which the existing retry loop (3 attempts) and Path B file-fallback already handle correctly:

  • Path A retries 3× → all time out (~9s) → Path B runs
  • On Linux (_keyringExpectedAvailable() === false), Path B mints ~/.pi/remote/identity.json (0600) and recovers automatically
  • Subsequent calls hit Path 0 and return in <1ms

A healthy secret service settles in milliseconds, so legitimate keyring reads/writes are unaffected by the timeout.

Testing

  • pnpm typecheck clean
  • ✅ All 21 storage tests pass (src/pairing/storage.test.ts)
  • ✅ Verified on the affected host (Ubuntu, gnome-keyring present but blocking in tmux): fresh first-run now returns in ~10s and self-heals by minting identity.json; second call is instant via Path 0
  • ✅ Patch is scoped to NapiKeyringBackendKeyStoreBackend interface and the InMemoryBackend test stub are untouched

Scope

Single file changed: pi-extension/src/pairing/storage.ts (+30/−3). No protocol, pairing, or relay changes.

The native @napi-rs/keyring calls (getPassword/setPassword via libsecret)
can hang indefinitely on Linux when gnome-keyring blocks waiting on a GUI
authorization prompt that cannot be shown in a headless / tmux / non-
interactive context. Because a hang never settles, the promise never
rejects, making the retry + file-fallback logic in
getOrCreateEd25519Keypair() unreachable — freezing the entire
/remote-pi pair bootstrap (the tmux panel locks up and must be killed).

Wrap each keyring op in a 3s timeout (Promise.race). A hang now becomes a
thrown error, which the existing retry loop (3 attempts) and Path B file
fallback already handle correctly. A healthy secret service settles in
milliseconds, so legitimate reads are unaffected.

Verified: fresh first-run on a hanging gnome-keyring now returns in ~10s
and self-heals by minting ~/.pi/remote/identity.json; subsequent calls are
instant via Path 0. All 21 storage tests pass.
IOC detection for Monero (XMRig) miner spread via opencode 1.4.7
web server exploit. Checks: cron dropper (176.65.148.250:6556),
~/.claude/CRON binary, XMRig config, machine-id dropper, camouflaged
processes (unicorn/gunicorn), active pool connections (:53535).
Outputs clear INFECTED/CLEAN verdict with remediation steps.

@jacobaraujo7 jacobaraujo7 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, the diagnosis is spot on. Converting the hang into a thrown error so the existing retry + Path B file fallback becomes reachable is exactly the right shape, and it lines up with the platform gate we already have (Linux minting identity.json is the intended recovery there).

Two things before I can merge:

1. Unrelated files, please drop them. The PR also adds check-malware.sh at the repo root and an evidence-* entry in .gitignore. Those look like leftovers from a local malware investigation on your machine and have nothing to do with the keyring fix. Your own PR description says "Single file changed: pi-extension/src/pairing/storage.ts", so I assume they got committed by accident. Could you remove both and keep the PR to storage.ts only?

2. The timeout never clears its timer. In _withTimeout, when the underlying promise wins the race the setTimeout is still pending. A pending timer keeps the Node event loop alive, so every successful keyring op now holds the process open for up to 3 extra seconds, which shows up as the CLI being slow to exit. Something like:

function _withTimeout<T>(p: Promise<T>, op: string, ms: number = KEYRING_OP_TIMEOUT_MS): Promise<T> {
  let t: NodeJS.Timeout;
  const timeout = new Promise<T>((_, reject) => {
    t = setTimeout(() => reject(new Error(`keyring ${op} timed out after ${ms}ms`)), ms);
    t.unref();
  });
  return Promise.race([p, timeout]).finally(() => clearTimeout(t));
}

With those two done this is good to go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants