Skip to content

Commit 0ce34ae

Browse files
authored
Merge pull request #91 from MongooseMoo/agent/issue-78-disconnect-registry
Replace manual disconnect teardown with owner resets
2 parents 3b19a0f + d77a944 commit 0ce34ae

34 files changed

Lines changed: 565 additions & 118 deletions

src/audio/MediaService.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -484,8 +484,16 @@ export class MediaService {
484484
}
485485
}
486486

487-
shutdown(): void {
487+
reset(): void {
488488
this.stopAllSounds();
489+
this.defaultUrl = '';
490+
this.currentMusic = undefined;
491+
this.mediaSession.clear();
492+
this.effects.shutdown();
493+
}
494+
495+
shutdown(): void {
496+
this.reset();
489497
if (this.shutdownComplete) {
490498
return;
491499
}
@@ -496,9 +504,6 @@ export class MediaService {
496504
}
497505
this.unsubscribePreferences?.();
498506
this.unsubscribePreferences = null;
499-
this.currentMusic = undefined;
500-
this.mediaSession.clear();
501-
this.effects.shutdown();
502507
}
503508

504509
private readonly handleWindowFocus = (): void => {

src/client.test.ts

Lines changed: 121 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ vi.mock('./gmcp', async () => {
110110
...actual,
111111
GMCPClientFileTransfer: class {
112112
packageName = 'Client.FileTransfer';
113+
reset = vi.fn();
113114
sendReject = vi.fn();
114115
shutdown = vi.fn();
115116
},
@@ -144,11 +145,8 @@ vi.mock('./mcp', () => ({
144145

145146
import MudClient from './client';
146147
import { GMCPClientFileTransfer } from './gmcp';
147-
import { useItemsStore } from './stores/itemsStore';
148148
import { useOutputStore } from './stores/outputStore';
149-
import { useSessionStore } from './stores/sessionStore';
150-
import { useSkillsStore } from './stores/skillsStore';
151-
import { useUserlistStore } from './stores/userlistStore';
149+
import type { Stream } from './telnet';
152150

153151
class MockWebSocket {
154152
static CONNECTING = 0;
@@ -215,13 +213,10 @@ describe('MudClient lifecycle cleanup', () => {
215213
mockFileTransferManagerInstances.length = 0;
216214
mockPreferenceListeners.clear();
217215
mockPreferenceSubscribe.mockClear();
216+
mockPreferencesState.general.localEcho = false;
218217
mockPreferencesState.sound.muteInBackground = false;
219218
mockWebSocketInstances.length = 0;
220-
useItemsStore.getState().reset();
221219
useOutputStore.getState().reset();
222-
useSessionStore.getState().reset();
223-
useSkillsStore.getState().reset();
224-
useUserlistStore.getState().reset();
225220
vi.stubGlobal('WebSocket', MockWebSocket);
226221
Object.defineProperty(window, 'WebSocket', {
227222
configurable: true,
@@ -292,43 +287,136 @@ describe('MudClient lifecycle cleanup', () => {
292287
expect(cleanupOrder).toEqual(['fileTransferManager.cleanup', 'gmcp.reset']);
293288
});
294289

295-
it('clears item state during connection cleanup', () => {
290+
it('runs disconnect resets in registration order and isolates failures', () => {
296291
const client = new MudClient('example.test', 443);
292+
const resetOrder: string[] = [];
293+
const resetError = new Error('reset failed');
294+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
295+
client.registerDisconnectReset(() => resetOrder.push('first'));
296+
client.registerDisconnectReset(() => {
297+
resetOrder.push('second');
298+
throw resetError;
299+
});
300+
client.registerDisconnectReset(() => resetOrder.push('third'));
297301
client.connect();
298-
useItemsStore.getState().setLocationItems('room', [
299-
{ id: 'lantern', name: 'Lantern' },
300-
]);
301-
useItemsStore.getState().setLocationItems('inv', [
302-
{ id: 'coin', name: 'Coin' },
303-
]);
304302

305303
client.close();
306304

307-
expect(useItemsStore.getState().itemsByLocation).toEqual({});
308-
expect(useItemsStore.getState().hasReceivedList).toBe(false);
305+
expect(resetOrder).toEqual(['first', 'second', 'third']);
306+
expect(consoleError).toHaveBeenCalledWith('Disconnect reset failed:', resetError);
309307
});
310308

311-
it('clears session, skills, and userlist state during connection cleanup', () => {
309+
it('runs disconnect resets once per disconnect and again after reconnect', () => {
312310
const client = new MudClient('example.test', 443);
311+
const reset = vi.fn();
312+
client.registerDisconnectReset(reset);
313313
client.connect();
314-
useSessionStore.getState().setPlayer('q', 'Q the Mongoose');
315-
useSessionStore.getState().setRoomId('101');
316-
useSkillsStore.getState().setGroups([{ name: 'Combat', rank: 'Adept' }]);
317-
useSkillsStore.getState().setList({ group: 'combat', list: ['slash'] });
318-
useUserlistStore.getState().setPlayers([
319-
{ Object: 'q', Name: 'Q', Icon: 0, away: false, idle: false },
320-
]);
314+
const firstSocket = mockWebSocketInstances[0];
315+
316+
client.close();
317+
firstSocket.onclose?.(new Event('close'));
318+
319+
expect(reset).toHaveBeenCalledTimes(1);
320+
321+
client.connect();
322+
client.close();
323+
324+
expect(reset).toHaveBeenCalledTimes(2);
325+
});
326+
327+
it('automatically registers MCP package disconnect resets', () => {
328+
const client = new MudClient('example.test', 443);
329+
const mcpPackage = client.registerMcpPackage(
330+
class {
331+
packageName = 'test-package';
332+
reset = vi.fn();
333+
} as never,
334+
) as unknown as { reset: ReturnType<typeof vi.fn> };
335+
client.connect();
336+
337+
client.close();
338+
339+
expect(mcpPackage.reset).toHaveBeenCalledOnce();
340+
});
341+
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;
321387

388+
expect(() => client.send('look\r\n')).toThrow(
389+
new Error('Cannot send while disconnected'),
390+
);
391+
expect(socket.send).not.toHaveBeenCalled();
322392
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);
323409

324-
expect(useSessionStore.getState().playerId).toBe('');
325-
expect(useSessionStore.getState().playerName).toBe('');
326-
expect(useSessionStore.getState().roomId).toBe('');
327-
expect(useSkillsStore.getState().groups).toEqual([]);
328-
expect(useSkillsStore.getState().skillsByGroup).toEqual({});
329-
expect(useSkillsStore.getState().infoBySkill).toEqual({});
330-
expect(useUserlistStore.getState().players).toEqual([]);
331-
expect(useUserlistStore.getState().hasReceivedList).toBe(false);
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([]);
332420
});
333421

334422
it('buffers text split across frames until the line is complete', async () => {

src/client.ts

Lines changed: 58 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,13 @@ import {
1616
McpSession,
1717
} from "./mcp";
1818

19-
import { MediaService } from "./audio/MediaService";
20-
import { AutoreadMode, usePreferences } from "./stores/preferencesStore";
21-
import { WebRTCService } from "./WebRTCService";
22-
import FileTransferManager from "./FileTransferManager.js";
23-
import { useRoomStore } from "./stores/roomStore";
24-
import { useSpatialStore } from "./stores/spatialStore";
25-
import { useLiveKitStore } from "./stores/liveKitStore";
19+
import { MediaService } from "./audio/MediaService";
20+
import { AutoreadMode, usePreferences } from "./stores/preferencesStore";
21+
import { WebRTCService } from "./WebRTCService";
22+
import FileTransferManager from "./FileTransferManager.js";
2623
import { useInputStore } from "./stores/inputStore";
27-
import { useItemsStore } from "./stores/itemsStore";
28-
import { useServerLinksStore } from "./stores/serverLinksStore";
29-
import { useWorldMapStore } from "./stores/worldMapStore";
3024
import { useConnectionStore } from "./stores/connectionStore";
31-
import { useCharacterStatusStore } from "./stores/characterStatusStore";
3225
import { useOutputStore } from "./stores/outputStore";
33-
import { useSessionStore } from "./stores/sessionStore";
34-
import { useSkillsStore } from "./stores/skillsStore";
35-
import { useUserlistStore } from "./stores/userlistStore";
3626

3727
function resetMidiIntentionalDisconnectFlags(): void {
3828
if (!usePreferences.getState().midi.enabled) return;
@@ -73,6 +63,7 @@ class MudClient {
7363
private _autosay: boolean = false;
7464
private connectionCleanupComplete: boolean = true;
7565
private shutdownComplete: boolean = false;
66+
private disconnectResetCallbacks: Array<() => void> = [];
7667
private cleanupCallbacks: Array<() => void> = [];
7768

7869
get autosay(): boolean {
@@ -101,11 +92,14 @@ class MudClient {
10192
this.fileTransferManager = new FileTransferManager(
10293
this.webRTCService,
10394
this.gmcp_fileTransfer,
104-
);
105-
}
95+
);
96+
this.registerDisconnectReset(() => this.fileTransferManager.cleanup());
97+
}
10698

10799
registerMcpPackage(p: new () => MCPPackage): MCPPackage {
108-
return this.mcpSession.registerPackage(p);
100+
const mcpPackage = this.mcpSession.registerPackage(p);
101+
this.registerDisconnectReset(() => mcpPackage.reset());
102+
return mcpPackage;
109103
}
110104

111105
configureEditors(simpleEdit: McpSimpleEdit): void {
@@ -266,40 +260,49 @@ class MudClient {
266260
}
267261

268262
public send(data: string) {
269-
if (this.localMode && this.localStream) {
263+
if (this._connected && this.localMode && this.localStream) {
270264
// In local mode, write through the stream (WorkerStream -> Worker)
271265
this.localStream.write(Buffer.from(data));
272-
} else {
273-
this.ws.send(data);
266+
return;
274267
}
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");
275277
}
276278

277279
registerCleanup(callback: () => void): void {
278280
this.cleanupCallbacks.push(callback);
279281
}
280-
281-
private cleanupConnection(): void {
282-
if (this.connectionCleanupComplete) return;
283-
this.connectionCleanupComplete = true;
284-
this._connected = false;
285-
this.mcpSession.reset();
286-
this.decoder = new TextDecoder("utf8");
282+
283+
registerDisconnectReset(callback: () => void): void {
284+
this.disconnectResetCallbacks.push(callback);
285+
}
286+
287+
private cleanupConnection(): void {
288+
if (this.connectionCleanupComplete) return;
289+
this.connectionCleanupComplete = true;
290+
this._connected = false;
291+
for (const callback of this.disconnectResetCallbacks) {
292+
try {
293+
callback();
294+
} catch (error) {
295+
console.error("Disconnect reset failed:", error);
296+
}
297+
}
298+
this.mcpSession.reset();
299+
this.decoder = new TextDecoder("utf8");
287300
this.telnetBuffer = "";
288-
useRoomStore.getState().reset(); // Reset room info on cleanup
289-
useSpatialStore.getState().reset(); // Reset spatial scene on cleanup
290-
useItemsStore.getState().reset();
291-
useWorldMapStore.getState().reset();
292-
useServerLinksStore.getState().reset();
293-
useInputStore.getState().resetCommands();
294-
useCharacterStatusStore.getState().reset();
295-
useSessionStore.getState().reset();
296-
useSkillsStore.getState().reset();
297-
useUserlistStore.getState().reset();
298-
this.fileTransferManager?.cleanup();
299301
this.gmcp.reset();
300-
useLiveKitStore.getState().reset();
302+
this.localMode = false;
303+
this.localStream = undefined;
301304
useConnectionStore.getState().setConnected(false);
302-
}
305+
}
303306

304307
public close(): void {
305308
this.intentionalDisconnect = true;
@@ -320,17 +323,23 @@ class MudClient {
320323
this.cleanupConnection();
321324
}
322325

323-
public sendCommand(command: string): void {
324-
const localEchoEnabled = usePreferences.getState().general.localEcho;
325-
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) {
326339
useOutputStore.getState().addCommand(command);
327340
}
328-
if (this.autosay && !command.startsWith("-") && !command.startsWith("'")) {
329-
command = `say ${command}`;
330-
}
331-
this.send(`${command}\r\n`);
332-
console.log(`> ${command}`);
333-
}
341+
console.log(`> ${command}`);
342+
}
334343

335344
/*
336345
<message> ::= <message-start>

0 commit comments

Comments
 (0)