Skip to main content

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 prefixStateWhat it means
CONNECTION_BUSYbusyAnother operation owns this connection. Use a different one.
CONNECTION_DEADdeadThe connection was torn down and can never carry another operation.
CONNECTION_NOT_READYnew / connectingIt 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.

The declaration is not the enforcement

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 of fn, and releases it in a finally on 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 with POOL_ACQUIRE_TIMEOUT. Prefer withConnection, which cannot leak an entry; acquire() returns a PooledConnection handle whose release() 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:

NameKindPurpose
ConnectionPoolclassThe per-session pool. withConnection, acquire, stats, close.
PooledConnectiontypeAn acquired connection plus the right to release() it once.
ConnectionFactorytype() => PromiseLike<TeConnection> — open and authenticated.
ConnectionPoolOptionstypefactory, min, max, acquireTimeoutMs, validation and idle knobs.
TeConnectiontypeThe narrow slice of a connection the pool depends on.
PoolStatstypesize, idle, waiting, min, max, closed.
PoolErrorclassEvery pool failure, carrying a code from the constants below.
SessionPoolRegistryclassPer-session pools plus the process-wide cap that bounds them.
SessionManagerclassThe sanctioned way into a session's connections; also withConnection.
CountingAggregateLimiterclassThe 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.

A per-pool maximum does not bound the total

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 0 means 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.