In the previous chapter, LSP Server Manager (The Router), we built the "Switchboard Operator." The Manager knows which tool handles Python and which handles TypeScript.
Now, we zoom in on the specific tools themselves. Meet The Worker.
Imagine you hire a contractor to renovate your kitchen. You cannot simply shout "Build!" and walk away. You need to manage the relationship:
In software, a "Language Server" is a separate computer program. It can crash, it takes time to load, and it might get busy. If our text editor tries to talk to a server that has crashed, the whole app might freeze.
The Server Instance is a wrapper around the actual language server process. It acts like a Supervisor.
It doesn't care about what the server is saying (e.g., specific Python syntax). It only cares about the server's health.
stopped, starting, running, or in an error state.The Router (from Chapter 2) creates these instances. Here is how it interacts with a Worker.
We define the worker with a name and a configuration (like the command to run).
const pythonWorker = createLSPServerInstance('python-server', {
command: 'pylsp',
args: ['--verbose']
});
// At this point, the worker is hired, but hasn't started working yet.
console.log(pythonWorker.state); // Output: 'stopped'
We tell the worker to start. This is asynchronous because booting up a server takes time.
try {
console.log("Starting up...");
await pythonWorker.start();
console.log("Ready to work!");
} catch (err) {
console.error("Worker failed to show up:", err);
}
When we need something done, we ask the instance. The instance checks if the worker is healthy first.
// The Instance handles the complexity of checking health
// and retrying if the worker is busy.
const definition = await pythonWorker.sendRequest(
'textDocument/definition',
{ ...params }
);
The LSPServerInstance is designed as a State Machine. It strictly controls the transitions between states to prevent bugs (like trying to start a server that is already running).
Let's look at LSPServerInstance.ts. We use a factory function to keep our variables private (Encapsulation).
We don't use a class. We use variables inside a function. Only the methods inside this function can touch these variables.
export function createLSPServerInstance(name: string, config: Config) {
// Private variables - no one outside can mess with these directly
let state: LspServerState = 'stopped'
let restartCount = 0
let lastError: Error | undefined
// The actual connection to the process (The Communicator)
const client = createLSPClient(name, (error) => {
// If the client reports an error, we update our state immediately
state = 'error'
lastError = error
})
// ... methods follow ...
}
This function manages the transition from stopped to running. It ensures we don't accidentally start twice.
async function start(): Promise<void> {
// 1. Guard Clause: Don't start if already running
if (state === 'running' || state === 'starting') return
try {
state = 'starting'
// 2. Actually boot up the process
await client.start(config.command, config.args, { ... })
// 3. Perform the handshake (Initialize)
await client.initialize({ ...initParams })
// 4. Success!
state = 'running'
} catch (error) {
state = 'error'
throw error
}
}
Explanation: Notice the state = 'starting' line. This prevents other parts of the app from trying to use the server while it's still waking up.
Sometimes a server is running but isn't ready. For example, rust-analyzer might say "Content Modified" if it's still indexing files. Our worker is smart enough to wait and retry.
async function sendRequest<T>(method: string, params: unknown): Promise<T> {
// 1. Health Check
if (!isHealthy()) throw new Error("Server is down!")
// 2. Retry Logic
for (let attempt = 0; attempt <= 3; attempt++) {
try {
return await client.sendRequest(method, params)
} catch (error) {
// If server says "I'm busy/modified", wait a bit and try again
if (isContentModifiedError(error)) {
await sleep(500) // Wait 0.5 seconds
continue // Try again
}
throw error // Real error, give up
}
}
}
Explanation: This retry loop makes the application feel much more stable to the user. Instead of an error message, they just experience a tiny delay.
If the server crashes, we can try to turn it off and on again. But we count how many times we do this so we don't get stuck in an infinite loop.
async function restart(): Promise<void> {
// 1. Stop the current broken process
try { await stop() } catch (e) { /* ignore */ }
// 2. Check limits
restartCount++
if (restartCount > 3) {
throw new Error("Server keeps crashing. I give up.")
}
// 3. Try to start again
await start()
}
The Server Instance (The Worker) is the reliable supervisor.
stopped -> running -> error).
However, notice that the Worker calls client.sendRequest or client.start. The Worker decides when to send a message, but it doesn't know how to format the message into the specific JSON format that servers understand.
That low-level translation is the job of The Communicator.
Next Chapter: Low-Level Client (The Communicator)
Generated by Code IQ