Skip to content

Commit 2f30a48

Browse files
committed
test: add compression and fetch stream memory leak tests
- Add compression stream cleanup tests (deflate/inflate) - Add compression stream listener leak tests over multiple cycles - Add fetch state cleanup tests (requestTagMap, folders) - Add mailbox operations listener leak tests with mock server - Create enhanced mock server supporting SELECT/FETCH operations
1 parent 2dfa6f3 commit 2f30a48

File tree

1 file changed

+314
-0
lines changed

1 file changed

+314
-0
lines changed

test/memory-leak-test.js

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66

77
const net = require('net');
8+
const zlib = require('zlib');
9+
const { PassThrough } = require('stream');
810
const { ImapFlow } = require('../lib/imap-flow');
911

1012
// Helper to get listener counts for key objects
@@ -349,5 +351,317 @@ exports['Memory Leak Tests'] = {
349351
}
350352
};
351353

354+
exports['Compression Stream Tests'] = {
355+
'should clean up compression streams on close'(test) {
356+
const client = new ImapFlow({
357+
host: '127.0.0.1',
358+
port: 1,
359+
secure: false,
360+
logger: false
361+
});
362+
363+
// Simulate compression being enabled by manually setting up streams
364+
client._deflate = zlib.createDeflateRaw();
365+
client._inflate = zlib.createInflateRaw();
366+
367+
// Add error handlers like compress() does
368+
client._deflate.on('error', () => {});
369+
client._inflate.on('error', () => {});
370+
371+
// Create writeSocket like compress() does
372+
const writeSocket = new PassThrough();
373+
writeSocket.on('readable', () => {});
374+
writeSocket.on('error', () => {});
375+
client.writeSocket = writeSocket;
376+
377+
// Verify streams exist
378+
test.ok(client._deflate, 'deflate should exist');
379+
test.ok(client._inflate, 'inflate should exist');
380+
test.ok(client.writeSocket, 'writeSocket should exist');
381+
382+
// Close the client
383+
client.close();
384+
385+
// Verify compression streams are cleaned up
386+
test.equal(client._deflate, null, 'deflate should be null after close');
387+
test.equal(client._inflate, null, 'inflate should be null after close');
388+
test.equal(client.writeSocket, null, 'writeSocket should be null after close');
389+
test.ok(client.isClosed, 'client should be closed');
390+
391+
test.done();
392+
},
393+
394+
'should not leak compression stream listeners over multiple cycles'(test) {
395+
// Create multiple deflate/inflate streams to verify they don't accumulate
396+
const streams = [];
397+
398+
for (let i = 0; i < 10; i++) {
399+
const client = new ImapFlow({
400+
host: '127.0.0.1',
401+
port: 1,
402+
secure: false,
403+
logger: false
404+
});
405+
406+
// Simulate compression setup
407+
client._deflate = zlib.createDeflateRaw();
408+
client._inflate = zlib.createInflateRaw();
409+
client._deflate.on('error', () => {});
410+
client._inflate.on('error', () => {});
411+
412+
streams.push({
413+
deflate: client._deflate,
414+
inflate: client._inflate
415+
});
416+
417+
// Close immediately
418+
client.close();
419+
420+
// Verify cleanup
421+
test.equal(client._deflate, null, `cycle ${i + 1}: deflate should be null`);
422+
test.equal(client._inflate, null, `cycle ${i + 1}: inflate should be null`);
423+
}
424+
425+
// Verify all streams are destroyed
426+
for (let i = 0; i < streams.length; i++) {
427+
test.ok(streams[i].deflate.destroyed, `cycle ${i + 1}: deflate should be destroyed`);
428+
test.ok(streams[i].inflate.destroyed, `cycle ${i + 1}: inflate should be destroyed`);
429+
}
430+
431+
test.done();
432+
},
433+
434+
'should handle compression stream errors without leaking'(test) {
435+
const client = new ImapFlow({
436+
host: '127.0.0.1',
437+
port: 1,
438+
secure: false,
439+
logger: false
440+
});
441+
442+
// Setup compression streams with error handlers
443+
client._deflate = zlib.createDeflateRaw();
444+
client._inflate = zlib.createInflateRaw();
445+
446+
// Add error handlers (like compress() does)
447+
client._deflate.on('error', () => {});
448+
client._inflate.on('error', () => {});
449+
450+
// Verify initial listener counts
451+
test.equal(client._deflate.listenerCount('error'), 1, 'deflate should have 1 error listener');
452+
test.equal(client._inflate.listenerCount('error'), 1, 'inflate should have 1 error listener');
453+
454+
// Close and verify cleanup
455+
client.close();
456+
457+
test.equal(client._deflate, null, 'deflate should be null');
458+
test.equal(client._inflate, null, 'inflate should be null');
459+
460+
test.done();
461+
}
462+
};
463+
464+
exports['Fetch Stream Tests'] = {
465+
'should clean up internal fetch state on close'(test) {
466+
const client = new ImapFlow({
467+
host: '127.0.0.1',
468+
port: 1,
469+
secure: false,
470+
logger: false
471+
});
472+
473+
// Simulate some internal state that would exist during fetch
474+
client.requestTagMap.set('A001', { tag: 'A001', command: 'FETCH' });
475+
client.requestTagMap.set('A002', { tag: 'A002', command: 'FETCH' });
476+
477+
test.equal(client.requestTagMap.size, 2, 'should have 2 pending requests');
478+
479+
// Close the client
480+
client.close();
481+
482+
// Verify request map is cleared
483+
test.equal(client.requestTagMap.size, 0, 'requestTagMap should be cleared after close');
484+
test.ok(client.isClosed, 'client should be closed');
485+
486+
test.done();
487+
},
488+
489+
'should clean up mailbox state on close'(test) {
490+
const client = new ImapFlow({
491+
host: '127.0.0.1',
492+
port: 1,
493+
secure: false,
494+
logger: false
495+
});
496+
497+
// Simulate mailbox state
498+
client.folders.set('INBOX', { path: 'INBOX', exists: 100 });
499+
client.folders.set('Sent', { path: 'Sent', exists: 50 });
500+
client.folders.set('Drafts', { path: 'Drafts', exists: 10 });
501+
502+
test.equal(client.folders.size, 3, 'should have 3 folders cached');
503+
504+
// Close the client
505+
client.close();
506+
507+
// Verify folders are cleared
508+
test.equal(client.folders.size, 0, 'folders should be cleared after close');
509+
510+
test.done();
511+
},
512+
513+
async 'should clean up after fetch with mock server'(test) {
514+
const server = createMockServerWithFetch();
515+
516+
server.listen(0, '127.0.0.1', async () => {
517+
const port = server.address().port;
518+
519+
const client = new ImapFlow({
520+
host: '127.0.0.1',
521+
port,
522+
secure: false,
523+
logger: false,
524+
auth: {
525+
user: 'test',
526+
pass: 'test'
527+
}
528+
});
529+
530+
try {
531+
await client.connect();
532+
533+
// Select INBOX
534+
await client.mailboxOpen('INBOX');
535+
536+
// Verify we're in selected state
537+
test.ok(client.mailbox, 'mailbox should be selected');
538+
539+
await client.logout();
540+
541+
// Wait for cleanup
542+
await new Promise(r => setTimeout(r, 100));
543+
544+
// Verify cleanup
545+
const report = getListenerReport(client);
546+
test.equal(report.streamer.error, 0, 'streamer error listeners should be 0');
547+
test.equal(report.streamer.readable, 0, 'streamer readable listeners should be 0');
548+
test.ok(client.isClosed, 'client should be closed');
549+
} catch (err) {
550+
test.ok(false, 'should not throw: ' + err.message);
551+
} finally {
552+
server.close();
553+
test.done();
554+
}
555+
});
556+
},
557+
558+
async 'should not leak listeners during multiple mailbox operations'(test) {
559+
const server = createMockServerWithFetch();
560+
561+
server.listen(0, '127.0.0.1', async () => {
562+
const port = server.address().port;
563+
564+
const client = new ImapFlow({
565+
host: '127.0.0.1',
566+
port,
567+
secure: false,
568+
logger: false,
569+
auth: {
570+
user: 'test',
571+
pass: 'test'
572+
}
573+
});
574+
575+
try {
576+
await client.connect();
577+
578+
// Perform multiple mailbox operations
579+
for (let i = 0; i < 5; i++) {
580+
await client.mailboxOpen('INBOX');
581+
await client.mailboxClose();
582+
}
583+
584+
await client.logout();
585+
586+
// Wait for cleanup
587+
await new Promise(r => setTimeout(r, 100));
588+
589+
// Verify no listener accumulation
590+
const report = getListenerReport(client);
591+
test.equal(report.streamer.error, 0, 'streamer error listeners should be 0');
592+
test.equal(report.streamer.readable, 0, 'streamer readable listeners should be 0');
593+
} catch (err) {
594+
test.ok(false, 'should not throw: ' + err.message);
595+
} finally {
596+
server.close();
597+
test.done();
598+
}
599+
});
600+
}
601+
};
602+
603+
// Create a mock server that supports SELECT/FETCH operations
604+
function createMockServerWithFetch() {
605+
const server = net.createServer(socket => {
606+
socket.write('* OK Mock IMAP Server ready\r\n');
607+
608+
socket.on('data', data => {
609+
const lines = data
610+
.toString()
611+
.split('\r\n')
612+
.filter(l => l.trim());
613+
614+
for (const line of lines) {
615+
const parts = line.split(' ');
616+
const tag = parts[0];
617+
const command = parts[1] ? parts[1].toUpperCase() : '';
618+
619+
if (command === 'CAPABILITY') {
620+
socket.write('* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n');
621+
socket.write(`${tag} OK CAPABILITY completed\r\n`);
622+
} else if (command === 'LOGIN') {
623+
socket.write(`${tag} OK LOGIN completed\r\n`);
624+
} else if (command === 'LOGOUT') {
625+
socket.write('* BYE Server logging out\r\n');
626+
socket.write(`${tag} OK LOGOUT completed\r\n`);
627+
socket.end();
628+
} else if (command === 'NAMESPACE') {
629+
socket.write('* NAMESPACE (("" "/")) NIL NIL\r\n');
630+
socket.write(`${tag} OK NAMESPACE completed\r\n`);
631+
} else if (command === 'COMPRESS') {
632+
socket.write(`${tag} NO COMPRESS not supported\r\n`);
633+
} else if (command === 'ENABLE') {
634+
socket.write(`${tag} OK ENABLE completed\r\n`);
635+
} else if (command === 'ID') {
636+
socket.write('* ID NIL\r\n');
637+
socket.write(`${tag} OK ID completed\r\n`);
638+
} else if (command === 'SELECT' || command === 'EXAMINE') {
639+
socket.write('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n');
640+
socket.write('* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Flags permitted\r\n');
641+
socket.write('* 10 EXISTS\r\n');
642+
socket.write('* 0 RECENT\r\n');
643+
socket.write('* OK [UIDVALIDITY 1234567890] UIDs valid\r\n');
644+
socket.write('* OK [UIDNEXT 11] Predicted next UID\r\n');
645+
socket.write(`${tag} OK [READ-WRITE] SELECT completed\r\n`);
646+
} else if (command === 'CLOSE') {
647+
socket.write(`${tag} OK CLOSE completed\r\n`);
648+
} else if (command === 'FETCH') {
649+
// Simple fetch response
650+
socket.write('* 1 FETCH (UID 1 FLAGS (\\Seen))\r\n');
651+
socket.write(`${tag} OK FETCH completed\r\n`);
652+
} else if (command === 'NOOP') {
653+
socket.write(`${tag} OK NOOP completed\r\n`);
654+
} else if (tag && command) {
655+
socket.write(`${tag} OK Command completed\r\n`);
656+
}
657+
}
658+
});
659+
660+
socket.on('error', () => {});
661+
});
662+
663+
return server;
664+
}
665+
352666
// Note: helpers (getListenerReport, measureMemory, createMockServer) are available
353667
// within this module for testing purposes

0 commit comments

Comments
 (0)