Bug Description
When several credentials use an SSH tunnel with identical SSH settings (same host, port, user and key), n8n shares one ssh2 client between them via SSHClientsManager. That shared client is destroyed as soon as the first credential that opened it has its connection pool expire, even while other credentials are still actively using it. Those other credentials then hang on "Executing node…" and fail, and keep failing until their own pool is dropped or another credential rebuilds the client.
Root cause, from the current master:
packages/core/src/execution-engine/ssh-clients-manager.ts, getClient(): clients are cached by sha1(JSON.stringify(sshConfig)). On a cache hit the method only does existing.lastUsed = new Date() and returns the client; the caller's abortController is not registered. Only the first caller's AbortController is stored with the registration, and its abort listener calls cleanupClient(key), which runs registration.client.end().
packages/nodes-base/nodes/Postgres/transport/index.ts: every Postgres credential gets its own pool via ConnectionPoolManager and passes its own abortController to this.helpers.getSSHClient(credentials, abortController). The local TCP proxy calls sshClient.forwardOut(...) on whatever client it was handed.
packages/nodes-base/utils/connection-pool-manager.ts: pools have a 5 minute TTL (5 * 60 * 1000, cleanup every 60 s). When a pool is reaped, registration.abortController.abort() is called.
So: credential A's pool creates the shared client and becomes its "owner". Credential B attaches to the same client (cache hit, controller ignored). Five minutes after A's last use, A's pool is reaped → A's controller aborts → cleanupClient → client.end(). B's proxy still points at the ended client; its next forwardOut fails, B's execution hangs and then errors. B keeps failing on retries until its pool is dropped. A, or any other credential, opens a fresh client on its next run and works.
This is different from #12807 / #16054 (single-credential pool not being recreated). That fix is present in the version we run; the failure here only happens with two or more credentials sharing one SSH client.
To Reproduce
Setup: two Postgres credentials pointing at different databases, both with "SSH Tunnel" enabled and identical SSH host, port, user and private key.
- Run a workflow using credential A once.
- Within a minute, start running a workflow using credential B once per minute. It works.
- About five minutes after A's run (A's pool TTL), B hangs on "Executing node…" and then fails.
- Run B again: fails again.
- Run A: works (opens a new SSH connection). B works again only after its own pool has been reaped or n8n is restarted.
We confirmed the mechanism from the SSH server side (Amazon Linux 2023, OpenSSH 9.9). All times UTC, 2026-09-05. Exact times are from the SSH server's log; times marked ~ are the manual runs as triggered in the n8n UI, and for the failing runs the point is precisely that nothing reached the server:
15:01:09 A runs -> new SSH login (shared client created, owned by A's pool)
~15:05:00 B runs through the same SSH connection -> works
15:06:37 n8n sends SSH disconnect: A's pool TTL (5 min after A's last use)
rounded up to the next 60 s cleanup tick. B had refreshed the SSH
client's lastUsed ~90 s earlier, so this is not the SSH idle timeout.
~15:07:00 B runs -> hangs on "Executing node…"; NO connection attempt reaches the server
~15:08:30 B runs again -> fails; still no connection attempt
15:09:08 A runs -> new SSH login -> works
Confirming test: after giving every credential a different SSH port (so the cache key differs), the server held 8 concurrent SSH sessions, one per credential, each expiring independently 5 minutes after its own last use, and no credential failed.
Expected behavior
A shared SSH client should not be ended while other dependents are still using it. Either:
- register every caller's
AbortController on a cache hit and only end the client when the last dependent has aborted (reference counting), or
- when the client is ended for any reason, abort all dependents' controllers so their proxies and pools are torn down and recreated on next use, instead of leaving them pointing at a dead client.
Debug Info
# Debug info
## core
- n8nVersion: 2.37.7
- platform: docker (cloud)
- nodeJsVersion: 26.5.1
- nodeEnv: production
- database: sqlite
- executionMode: regular
- concurrency: 5
- license: enterprise (sandbox)
- consumerId: 00000000-0000-0000-0000-000000000000
## storage
- success: all
- error: all
- progress: false
- manual: true
- binaryMode: filesystem
## pruning
- enabled: true
- maxAge: 168 hours
- maxCount: 2500 executions
## client
- userAgent: mozilla/5.0 (macintosh; intel mac os x 10_15_7) applewebkit/537.36 (khtml, like gecko) chrome/152.0.0.0 safari/537.36
- isTouchDevice: false
Generated at: 2026-09-05T16:55:31.450Z
Operating System
n8n Cloud
n8n Version
2.37.7 (n8n Cloud)
Node.js Version
26.5.1
Database
SQLite (default)
Execution mode
main (default)
Hosting
n8n cloud
Workaround
Give each credential a distinct SSH port (the SSH server can listen on, or redirect, a small range of ports). Different port → different cache key → one SSH client per credential.
Bug Description
When several credentials use an SSH tunnel with identical SSH settings (same host, port, user and key), n8n shares one
ssh2client between them viaSSHClientsManager. That shared client is destroyed as soon as the first credential that opened it has its connection pool expire, even while other credentials are still actively using it. Those other credentials then hang on "Executing node…" and fail, and keep failing until their own pool is dropped or another credential rebuilds the client.Root cause, from the current
master:packages/core/src/execution-engine/ssh-clients-manager.ts,getClient(): clients are cached bysha1(JSON.stringify(sshConfig)). On a cache hit the method only doesexisting.lastUsed = new Date()and returns the client; the caller'sabortControlleris not registered. Only the first caller'sAbortControlleris stored with the registration, and itsabortlistener callscleanupClient(key), which runsregistration.client.end().packages/nodes-base/nodes/Postgres/transport/index.ts: every Postgres credential gets its own pool viaConnectionPoolManagerand passes its ownabortControllertothis.helpers.getSSHClient(credentials, abortController). The local TCP proxy callssshClient.forwardOut(...)on whatever client it was handed.packages/nodes-base/utils/connection-pool-manager.ts: pools have a 5 minute TTL (5 * 60 * 1000, cleanup every 60 s). When a pool is reaped,registration.abortController.abort()is called.So: credential A's pool creates the shared client and becomes its "owner". Credential B attaches to the same client (cache hit, controller ignored). Five minutes after A's last use, A's pool is reaped → A's controller aborts →
cleanupClient→client.end(). B's proxy still points at the ended client; its nextforwardOutfails, B's execution hangs and then errors. B keeps failing on retries until its pool is dropped. A, or any other credential, opens a fresh client on its next run and works.This is different from #12807 / #16054 (single-credential pool not being recreated). That fix is present in the version we run; the failure here only happens with two or more credentials sharing one SSH client.
To Reproduce
Setup: two Postgres credentials pointing at different databases, both with "SSH Tunnel" enabled and identical SSH host, port, user and private key.
We confirmed the mechanism from the SSH server side (Amazon Linux 2023, OpenSSH 9.9). All times UTC, 2026-09-05. Exact times are from the SSH server's log; times marked
~are the manual runs as triggered in the n8n UI, and for the failing runs the point is precisely that nothing reached the server:Confirming test: after giving every credential a different SSH port (so the cache key differs), the server held 8 concurrent SSH sessions, one per credential, each expiring independently 5 minutes after its own last use, and no credential failed.
Expected behavior
A shared SSH client should not be ended while other dependents are still using it. Either:
AbortControlleron a cache hit and only end the client when the last dependent has aborted (reference counting), orDebug Info
Operating System
n8n Cloud
n8n Version
2.37.7 (n8n Cloud)
Node.js Version
26.5.1
Database
SQLite (default)
Execution mode
main (default)
Hosting
n8n cloud
Workaround
Give each credential a distinct SSH port (the SSH server can listen on, or redirect, a small range of ports). Different port → different cache key → one SSH client per credential.