Skip to content

Commit d77a944

Browse files
committed
Reject commands on closed transports
1 parent eac9c7f commit d77a944

2 files changed

Lines changed: 110 additions & 12 deletions

File tree

src/client.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ vi.mock('./mcp', () => ({
146146
import MudClient from './client';
147147
import { GMCPClientFileTransfer } from './gmcp';
148148
import { useOutputStore } from './stores/outputStore';
149+
import type { Stream } from './telnet';
149150

150151
class MockWebSocket {
151152
static CONNECTING = 0;
@@ -212,6 +213,7 @@ describe('MudClient lifecycle cleanup', () => {
212213
mockFileTransferManagerInstances.length = 0;
213214
mockPreferenceListeners.clear();
214215
mockPreferenceSubscribe.mockClear();
216+
mockPreferencesState.general.localEcho = false;
215217
mockPreferencesState.sound.muteInBackground = false;
216218
mockWebSocketInstances.length = 0;
217219
useOutputStore.getState().reset();
@@ -337,6 +339,86 @@ describe('MudClient lifecycle cleanup', () => {
337339
expect(mcpPackage.reset).toHaveBeenCalledOnce();
338340
});
339341

342+
it('clears a closed local transport and rejects later sends', () => {
343+
const client = new MudClient('example.test', 443);
344+
const closeListeners: Array<() => void> = [];
345+
const stream = {
346+
close: vi.fn(() => {
347+
closeListeners.forEach((listener) => {
348+
listener();
349+
});
350+
}),
351+
on: vi.fn((event: string, callback: () => void) => {
352+
if (event === 'close') closeListeners.push(callback);
353+
}),
354+
write: vi.fn(),
355+
} as unknown as Stream & { close(): void };
356+
client.connectLocal(stream);
357+
358+
client.send('look\r\n');
359+
expect(stream.write).toHaveBeenCalledOnce();
360+
361+
client.close();
362+
363+
expect(stream.close).toHaveBeenCalledOnce();
364+
expect(client.connected).toBe(false);
365+
expect(
366+
client as unknown as { localMode: boolean; localStream?: Stream },
367+
).toMatchObject({
368+
localMode: false,
369+
localStream: undefined,
370+
});
371+
expect(() => client.send('look\r\n')).toThrow(
372+
new Error('Cannot send while disconnected'),
373+
);
374+
expect(stream.write).toHaveBeenCalledOnce();
375+
});
376+
377+
it.each([
378+
['CONNECTING', MockWebSocket.CONNECTING],
379+
['CLOSING', MockWebSocket.CLOSING],
380+
['CLOSED', MockWebSocket.CLOSED],
381+
])('rejects sends while the WebSocket is %s', (_label, readyState) => {
382+
const client = new MudClient('example.test', 443);
383+
client.connect();
384+
const socket = mockWebSocketInstances[0];
385+
socket.onopen?.(new Event('open'));
386+
socket.readyState = readyState;
387+
388+
expect(() => client.send('look\r\n')).toThrow(
389+
new Error('Cannot send while disconnected'),
390+
);
391+
expect(socket.send).not.toHaveBeenCalled();
392+
client.close();
393+
});
394+
395+
it('sends through a connected open WebSocket', () => {
396+
const client = new MudClient('example.test', 443);
397+
client.connect();
398+
const socket = mockWebSocketInstances[0];
399+
socket.onopen?.(new Event('open'));
400+
401+
client.send('look\r\n');
402+
403+
expect(socket.send).toHaveBeenCalledWith('look\r\n');
404+
});
405+
406+
it('reports a disconnected command without adding local echo', () => {
407+
mockPreferencesState.general.localEcho = true;
408+
const client = new MudClient('example.test', 443);
409+
410+
expect(() => client.sendCommand('look')).not.toThrow();
411+
412+
expect(useOutputStore.getState().entries).toEqual([
413+
{
414+
id: 1,
415+
type: 'error',
416+
error: new Error('Cannot send while disconnected'),
417+
},
418+
]);
419+
expect(mockWebSocketInstances).toEqual([]);
420+
});
421+
340422
it('buffers text split across frames until the line is complete', () => {
341423
const client = new MudClient('example.test', 443);
342424
client.connect();

src/client.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,20 @@ class MudClient {
260260
}
261261

262262
public send(data: string) {
263-
if (this.localMode && this.localStream) {
263+
if (this._connected && this.localMode && this.localStream) {
264264
// In local mode, write through the stream (WorkerStream -> Worker)
265265
this.localStream.write(Buffer.from(data));
266-
} else {
267-
this.ws.send(data);
266+
return;
268267
}
268+
if (
269+
this._connected &&
270+
this.ws &&
271+
this.ws.readyState === WebSocket.OPEN
272+
) {
273+
this.ws.send(data);
274+
return;
275+
}
276+
throw new Error("Cannot send while disconnected");
269277
}
270278

271279
registerCleanup(callback: () => void): void {
@@ -291,6 +299,8 @@ class MudClient {
291299
this.decoder = new TextDecoder("utf8");
292300
this.telnetBuffer = "";
293301
this.gmcp.reset();
302+
this.localMode = false;
303+
this.localStream = undefined;
294304
useConnectionStore.getState().setConnected(false);
295305
}
296306

@@ -313,17 +323,23 @@ class MudClient {
313323
this.cleanupConnection();
314324
}
315325

316-
public sendCommand(command: string): void {
317-
const localEchoEnabled = usePreferences.getState().general.localEcho;
318-
if (localEchoEnabled) {
326+
public sendCommand(command: string): void {
327+
if (this.autosay && !command.startsWith("-") && !command.startsWith("'")) {
328+
command = `say ${command}`;
329+
}
330+
try {
331+
this.send(`${command}\r\n`);
332+
} catch (error) {
333+
useOutputStore
334+
.getState()
335+
.addError(error instanceof Error ? error : new Error(String(error)));
336+
return;
337+
}
338+
if (usePreferences.getState().general.localEcho) {
319339
useOutputStore.getState().addCommand(command);
320340
}
321-
if (this.autosay && !command.startsWith("-") && !command.startsWith("'")) {
322-
command = `say ${command}`;
323-
}
324-
this.send(`${command}\r\n`);
325-
console.log(`> ${command}`);
326-
}
341+
console.log(`> ${command}`);
342+
}
327343

328344
/*
329345
<message> ::= <message-start>

0 commit comments

Comments
 (0)