The connection model
One connection carries exactly one operation at a time. A second operation started while the first is still in flight is rejected, not queued. Concurrency requires more sockets, which means it requires the pool.
This is the first thing to know about the TypeScript agent, and it is the one thing its API shape actively hides: every operation returns a Promise, and a Promise-returning client is normally safe to drive concurrently. This one is not.
Why there is only one
The 8.1 key server wire protocol carries no request/response correlation id. A frame is a type, a function id, a version, a length-prefixed user agent and then positional value vectors — there is no field anywhere in it that says which request a response belongs to. The agent therefore matches responses to requests positionally, by byte count, through a single FIFO read queue.
Two operations overlapping on one socket do not race for the same answer and lose. They interleave their writes and steal each other's reads, and both come back with plausible, wrong data. That is silent corruption, not an error — which is why the agent refuses the second operation outright rather than trying to be clever about it.
Multiplexing over a single socket cannot be added on the client side. It would take a change to the key server protocol.
What a second concurrent call actually does
Connection.runExclusive(fn) guards the whole request/response conversation. Its
rejection for a busy connection carries this message, exactly:
CONNECTION_BUSY: another operation is already in flight on this connection
It has two siblings from the same guard, which are worth telling apart because they mean different things about what to do next:
| Message prefix | State | What it means |
|---|---|---|
CONNECTION_BUSY | busy | Another operation owns this connection. Use a different one. |
CONNECTION_DEAD | dead | The connection was torn down and can never carry another operation. |
CONNECTION_NOT_READY | new / connecting | It has not been dialled, or the dial has not finished. |
CONNECTION_NOT_READY names the remedy in its own text, and the two remedies differ — a
new connection is told to call open(), a connecting one is told to wait for
open() to resolve.
connection-types.ts declares runExclusive<T>(fn: () => T | PromiseLike<T>): Promise<T> and nothing more — it is a declarations-only file, so it states the signature
and cannot state the guard. The busy check, the state machine and the throw are all in
connection.ts. Reading the declaration tells you the shape; only the implementation
tells you that a second caller is refused.
The unit of exclusion is a conversation, not a call
The lock is deliberately coarse. One login is five writes against four reads: the authentication context writes its init frame without reading a response, then runs four update round trips. Requests and responses are not paired one-to-one across that flow, so a lock at per-message granularity would leave a gap through which another operation consumes the response the login is waiting for.
So the lock is taken by the operation-level entry points — package execution,
authentication, and the liveness ping — and not by the inner primitive they are all
built on. In practice this means: whatever you run inside the pool's withConnection
holds the connection until your function settles, however many round trips that takes,
which is what lets a read-then-write sequence stay atomic on one socket.
Concurrency: use the pool
ConnectionPool is the mechanism. Concurrency N requires N sockets; there is no
other lever.
import { Administration, ConnectionPool } from 'server-ts-agent';
import type { AdministrationInstance, ConnectionInstance } from 'server-ts-agent';
const pool = new ConnectionPool({
// The pool never sees a credential. It takes a factory that yields a
// connection which is already open AND already authenticated.
factory: async () => openAndAuthenticate(),
max: 4, // DEFAULT_MAX_SIZE
acquireTimeoutMs: 10_000, // DEFAULT_ACQUIRE_TIMEOUT_MS
});
// An admin client is CONSTRUCTED EMPTY and then attached to a connection.
// Every member dereferences the attached connection with no guard, so calling
// one before attachConnection() is a synchronous TypeError, not a rejection.
function adminOn(conn: ConnectionInstance): AdministrationInstance {
const admin = new Administration();
admin.attachConnection(conn);
return admin;
}
// CORRECT. Each operation gets its own connection for the whole of its callback.
const [roles, groups] = await Promise.all([
pool.withConnection((conn) => adminOn(conn).getAllRoles()),
pool.withConnection((conn) =>
adminOn(conn).getPrincipalListWithClassNames(['GroupInfo'])
),
]);
And the shape that looks identical and is not:
// WRONG. One connection, two operations. The second rejects with CONNECTION_BUSY
// -- and it rejects only because the guard exists; without it this would corrupt.
const admin = adminOn(conn);
const [roles, groups] = await Promise.all([
admin.getAllRoles(),
admin.getPrincipalListWithClassNames(['GroupInfo']),
]);
The wrong version is not a rare hazard. It is what Promise.all over a client object
looks like everywhere else in Node, it passes review, and it works whenever the calls
happen not to overlap — which is most of the time in development and none of the time
under load.
What the pool guarantees
withConnection(fn)holds one connection for the whole offn, and releases it in afinallyon both the success and the failure path. There is deliberately no acquire/release that hands the connection back between round trips: a multi-step operation must stay on one socket.acquire()never returns null and never resolves empty. On exhaustion it waits; on timeout it rejects withPOOL_ACQUIRE_TIMEOUT. PreferwithConnection, which cannot leak an entry;acquire()returns aPooledConnectionhandle whoserelease()is idempotent, and exists for callers whose lifetime does not nest inside one function.- Growth is lazy. Nothing is pre-warmed, because a per-session pool is built on the login path and pre-warming N connections would put N handshakes into user-visible login latency for capacity the session may never use.
- Entries are validated on acquire with a liveness ping by default, evicted on failure, and reaped after an idle timeout down to a floor.
Pool identity is per session
The pool never takes a credential. It takes a ConnectionFactory — a closure that
produces a connection already open and already authenticated — and the session tier is
what closes over the token. Every connection in one pool therefore carries that session's
own identity, and authorization stays where it belongs: with the key server, decided
against the session principal, never migrated into the agent.
This is enforced structurally rather than by convention. There is no parameter on the pool that could carry a shared service identity, because the pool takes a factory instead of credentials.
Naming what you get
The concurrency surface the barrel exports:
| Name | Kind | Purpose |
|---|---|---|
ConnectionPool | class | The per-session pool. withConnection, acquire, stats, close. |
PooledConnection | type | An acquired connection plus the right to release() it once. |
ConnectionFactory | type | () => PromiseLike<TeConnection> — open and authenticated. |
ConnectionPoolOptions | type | factory, min, max, acquireTimeoutMs, validation and idle knobs. |
TeConnection | type | The narrow slice of a connection the pool depends on. |
PoolStats | type | size, idle, waiting, min, max, closed. |
PoolError | class | Every pool failure, carrying a code from the constants below. |
SessionPoolRegistry | class | Per-session pools plus the process-wide cap that bounds them. |
SessionManager | class | The sanctioned way into a session's connections; also withConnection. |
CountingAggregateLimiter | class | The aggregate permit implementation the registry requires. |
Defaults are exported as constants rather than buried: DEFAULT_MIN_SIZE,
DEFAULT_MAX_SIZE, DEFAULT_ACQUIRE_TIMEOUT_MS, DEFAULT_VALIDATE_TIMEOUT_MS,
DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_KEEPALIVE_INTERVAL_MS, DEFAULT_SWEEP_INTERVAL_MS.
With per-session pools, S sessions of N entries each is what reaches the key server.
SessionPoolRegistry requires the process-wide aggregate explicitly and has no
default for it, precisely so that nobody acquires a process-wide ceiling by accident.
Sessions on top
SessionManager.withConnection(sessionId, fn) is the layer most integrations actually
call. It does what the pool does, plus the accounting that keeps session expiry honest: a
session with work in flight is never idle, so it is never eligible for reaping, however
long fn takes — and the release is in a finally, so a throwing fn cannot pin a
session busy forever.
Session lifetime is derived rather than chosen. The idle TTL comes from the key server's
own idle window, read opportunistically, so that a session dies on its own honest
schedule instead of surfacing a re-authentication failure at some arbitrary later moment
when the token turns out to have been reaped upstream. deriveSessionIdleTtlMs()
guarantees the derived TTL plus the reap interval stays inside the key server's window.
The probe that reads that window is best effort by design: the configuration read it uses is role-gated, and a session whose principal lacks the role cannot call it, so a caller must keep a conservative fallback. Both the probe and the fallback constant are exported.
Two session-lifetime operations worth knowing
- Releasing a token tells the key server. Dropping an in-memory token and saying
nothing leaves the credential live upstream until an idle sweep reaps it. The exported
release call closes that gap. It takes a required delay argument, and
0means synchronous — neither of which is discoverable from the operation id, so both are exported as named constants. - Checking a session is not a ping. The session-validity operation answers whether the principal is still active, which a liveness ping cannot: a pooled connection stays authenticated on behalf of an account that has since been disabled.