From 5c887d4d3ca5681b5f242b06414ffde7cd539e1a Mon Sep 17 00:00:00 2001 From: slach Date: Thu, 19 Mar 2026 20:22:00 +0400 Subject: [PATCH 1/2] fix: add 3s timeout to keychain init to prevent hang on Linux without display On Linux in SSH sessions without DISPLAY/WAYLAND_DISPLAY, the gnome-keyring-daemon may be running but locked. libsecret's D-Bus calls then wait indefinitely for a GUI unlock dialog that never appears, causing `ncp list` and all other commands to hang forever with no output. Add a 3-second Promise.race timeout around the keychain test. On timeout the code falls back to the existing encrypted-file credential storage, so all NCP functionality continues to work correctly in headless/SSH environments. Fixes: ncp list hanging in SSH sessions on Linux even with gnome-keyring running. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/auth/secure-credential-store.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/auth/secure-credential-store.ts b/src/auth/secure-credential-store.ts index 95f3ec3..fedcba7 100644 --- a/src/auth/secure-credential-store.ts +++ b/src/auth/secure-credential-store.ts @@ -62,11 +62,26 @@ export class SecureCredentialStore { const keyring = await import('@napi-rs/keyring'); this.Entry = keyring.Entry; - // Test if keychain is accessible + // Test if keychain is accessible with a timeout. + // On Linux without a display (e.g. SSH sessions), the keyring daemon may be + // running but locked — libsecret waits indefinitely for the GUI unlock dialog + // that never appears. A 3s timeout lets us fall back to encrypted file storage. const testEntry = new this.Entry(SERVICE_NAME, '_ncp_test_'); - await testEntry.setPassword('test'); - await testEntry.getPassword(); - await testEntry.deletePassword(); + const timeoutMs = 3000; + const timeoutError = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Keychain test timed out after ${timeoutMs}ms — keyring locked or no display?`)), + timeoutMs + ) + ); + await Promise.race([ + (async () => { + await testEntry.setPassword('test'); + await testEntry.getPassword(); + await testEntry.deletePassword(); + })(), + timeoutError + ]); this.keychainAvailable = true; logger.debug('OS keychain initialized successfully'); From cb16ef65f4867d77ea23f24d0c6f939cd350039b Mon Sep 17 00:00:00 2001 From: slach Date: Thu, 19 Mar 2026 21:12:56 +0400 Subject: [PATCH 2/2] fix: skip keychain on Linux without display to prevent event loop hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @napi-rs/keyring uses native N-API which runs synchronously on the Node.js main thread. On Linux without DISPLAY/WAYLAND_DISPLAY (SSH/headless), the gnome-keyring-daemon is running but locked — libsecret's D-Bus call blocks the entire event loop waiting for a GUI unlock dialog that never appears. Promise.race with setTimeout cannot help here because the native call holds the event loop and setTimeout never fires. Fix: check for Linux + no display before importing @napi-rs/keyring at all, and fall back immediately to encrypted file storage. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/auth/secure-credential-store.ts | 34 +++++++++++++---------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/auth/secure-credential-store.ts b/src/auth/secure-credential-store.ts index fedcba7..1d41766 100644 --- a/src/auth/secure-credential-store.ts +++ b/src/auth/secure-credential-store.ts @@ -58,30 +58,26 @@ export class SecureCredentialStore { */ private async initializeKeychain(): Promise { try { + // On Linux without a display session, libsecret's D-Bus call to the Secret + // Service blocks the Node.js event loop indefinitely — the keyring daemon is + // running but locked, and the GUI unlock dialog never appears in SSH/headless + // environments. Since @napi-rs/keyring uses native N-API (not async I/O), + // Promise.race cannot interrupt it. Bail out early instead. + if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + this.keychainAvailable = false; + logger.debug('Skipping OS keychain: Linux without display (headless/SSH) — using encrypted file storage'); + return; + } + // Dynamically import keyring const keyring = await import('@napi-rs/keyring'); this.Entry = keyring.Entry; - // Test if keychain is accessible with a timeout. - // On Linux without a display (e.g. SSH sessions), the keyring daemon may be - // running but locked — libsecret waits indefinitely for the GUI unlock dialog - // that never appears. A 3s timeout lets us fall back to encrypted file storage. + // Test if keychain is accessible const testEntry = new this.Entry(SERVICE_NAME, '_ncp_test_'); - const timeoutMs = 3000; - const timeoutError = new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Keychain test timed out after ${timeoutMs}ms — keyring locked or no display?`)), - timeoutMs - ) - ); - await Promise.race([ - (async () => { - await testEntry.setPassword('test'); - await testEntry.getPassword(); - await testEntry.deletePassword(); - })(), - timeoutError - ]); + await testEntry.setPassword('test'); + await testEntry.getPassword(); + await testEntry.deletePassword(); this.keychainAvailable = true; logger.debug('OS keychain initialized successfully');