Skip to main content

Scripted Faults

ImapKit is strict and correct by default. Real servers are not: they answer with NO when you least expect it, cut a response in the middle of a literal, send a FETCH after the tagged OK, or drop the connection while the client is idling. Script rules let you put those faults into a test at exactly the point you want, and nowhere else.

A rule watches one kind of event (a command line from the client, a response the server is about to send, a quiet period ...), narrows it down with matchers, and says what to do instead with actions:

test/faults.test.js
import imapkit from 'imapkit';

const server = imapkit({
plugins: ['IDLE'],
script: [
// the first SELECT gets NO, the next ones run as usual
{ on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' },
// the body of message 1 is cut short and the connection dropped
{ on: 'response', command: 'FETCH', match: /^\* 1 FETCH .*BODY\[\]/, truncate: 40 }
]
});
const port = await server.start();

Faults change only the output and the handling of the lines a rule matches. The state of the server stays consistent: a LOGIN that a rule answers with OK does not log the session in, and a dropped EXPUNGE response still removes the message. Script rules are for tests only, a rule can send anything at all.

Adding and removing rules​

Rules come from two places:

  • the script server option, a rule or a list of rules, added when the server is built
  • server.script.add(rule) or server.script.add([rules]) at runtime, also while clients are connected

Rules are checked in the order they were added. The rules of quirk presets come right after the rules of the script option, and rules added later with server.script.add() after those.

add() returns a handle for a rule, or a list of handles for a list of rules:

Handle propertyMeaning
idthe number of the rule, 1 for the first rule added to the server
rulea frozen copy of the rule, changing your object later does not change the rule
matchedevents that matched the rule's matchers, also those before nth or after times
hitsevents the rule actually handled
remove()removes the rule

server.script.rules lists the handles in the order they are checked, and server.script.clear() removes every rule.

const [literals, late] = server.script.add([
// every string the grammar allows is sent as a literal
{ on: 'response', untagged: true, literals: true },
// the FETCH response of UID 2 arrives after the tagged OK
{ on: 'response', command: 'UID FETCH', untagged: true, match: /UID 2\b/, times: 1, defer: 'tagged' }
]);

// ... run the client

assert.strictEqual(late.hits, 1);
literals.remove();

A list is checked as a whole before any rule of it is added: if one rule is invalid, none are added.

note

server.control.reset() restores mailboxes and users but keeps the script rules, together with their matched and hits counters. A times: 1 rule that already fired stays used up. Call server.script.clear() and add the rules again when a test needs them fresh. See Repeatable tests.

Events​

Every rule has an on key that names the event it watches:

EventWhat it is
greetingthe * OK ImapKit ready for rumble greeting of a new connection
commanda complete command line from the client, with its literals. The rule acts instead of the parser and the command handler, so it also matches lines that do not parse and commands that do not exist
inputa line read by a command that takes over the input: DONE of IDLE, or a SASL response of AUTHENTICATE
responseevery response the server sends with connection.send(), tagged and untagged, as the exact bytes about to go out, after every plugin and the core changed the response
continuationa + continuation request: for a synchronizing literal, IDLE, or AUTHENTICATE
quietthe session had no input and no output for quietFor milliseconds. Whatever the rule sends starts the next quiet time. While IDLE runs, the event belongs to the IDLE command, so command: 'IDLE' matches it

Some details worth knowing:

  • A command rule is chosen when the line arrives, so matchers like state see the session as it was at that moment. The rule acts when the command's turn comes, so the responses of pipelined commands stay in order.
  • For a command with literals, the command event fires once for the whole command. Its data is the line with the literal data, without the final CRLF, e.g. A1 APPEND INBOX {5}\r\nhello.
  • The name of an AUTHENTICATE command includes the mechanism (AUTHENTICATE PLAIN), and UID commands include the subcommand (UID FETCH).
  • An unsolicited response (for example an EXISTS another session caused) belongs to the command that is running, or to the command that reads input, like IDLE.

Matchers​

All the matchers a rule gives have to match. The first rule that matches and is not used up handles the event, so a later rule can handle what an earlier one leaves alone.

MatcherEventsMatches
commandall but greetinga command name or a list of names, case-insensitive ('FETCH', ['FETCH', 'UID FETCH'])
tagall but greetingthe command tag, a string matches exactly, or a RegExp
descriptionresponse, continuationthe description the server passed to connection.send(), or a list of them (see Finding descriptions)
untaggedresponsetrue for untagged responses only, false for tagged ones only
sessionallthe number of the connection, or a list of numbers, 1 for the first connection the server accepted
stateallthe session state, 'Not Authenticated', 'Authenticated' or 'Selected', or a list of them
userallthe name of the authenticated user
mailboxallthe path of the selected mailbox ('INBOX', 'Archive')
matchalla RegExp, or a string with a regular expression, tested against the event data: the command line, the input line, or the output bytes. Global and sticky flags are dropped
whenalla function that gets the event context and returns true to match
nthallthe rule fires from the nth matching event on (default 1)
timesallthe rule fires this many times at most, then lets later rules handle the event
chanceallthe rule fires on a matching event with this probability, a number from 0 to 1. The random numbers come from the scriptSeed option, see Repeatable tests
quietForquiet (required there)milliseconds without input or output, a positive integer

The counting matchers work in this order: an event that passes every other matcher counts toward matched, then nth and times decide whether the rule may fire, and only then is a random number drawn for chance. when runs last among the other matchers, so it sees only events that passed them.

Actions​

Actions say what happens instead of the usual behavior. A rule needs at least one action.

ActionEventsEffect
sendalloutput events: bytes sent instead of the output. command and input: bytes sent instead of processing the line. quiet: bytes sent when the time is up
runcommand, inputprocess the line as usual after send, to add output before the real response
dropgreeting, command, input, response, continuationoutput events: send nothing. command and input: ignore the line, the client gets no answer
mutateresponse, continuation(response, context) gets a copy of the response object before it is compiled, and changes it or returns another one
literalsresponsesends every string of the response that the grammar allows as a literal
deferresponseholds an untagged response back: 'tagged' sends it right after the tagged response of its command, 'next' with the answer to the next command, before its first response
beforegreeting, response, continuationbytes sent before the output
aftergreeting, response, continuationbytes sent after the output
delaygreeting, command, response, continuationmilliseconds to wait. Output: before it goes out, and all later output waits behind it. Command: before the rule acts or the command runs, and later commands wait too
chunkallwrite the bytes in pieces of this many octets
chunkDelayall, needs chunkmilliseconds between the pieces, default 10. 0 or 'tick' sends each piece on its own event loop turn, so the pieces leave as separate TCP segments without a wall clock delay
truncateallsend only this many octets of the bytes, then close the connection
closeallclose the connection after the bytes are sent. 'reset' destroys the socket instead (a TCP RST where the runtime supports it), 20 ms after the bytes so the RST does not overtake them. Input that arrives meanwhile is not processed

Bytes: strings, Buffers and functions​

send, before and after take a string, a Buffer, or a function that gets the event context and returns a string or a Buffer.

  • Nothing is added: a response needs its own \r\n.
  • $TAG in a string is replaced with the tag of the command, or * when the event has no tag (the greeting, unsolicited responses).
  • A string is a binary string, one character per octet, like everywhere in ImapKit. If it contains a character above U+00FF, the whole string is sent as UTF-8 instead. So 'caf\xc3\xa9' and 'café ✓' both arrive as valid UTF-8, while 'café' alone (all characters below U+0100) arrives as the single octet 0xE9 for é.
  • A Buffer is sent as it is, $TAG is not replaced in it.

mutate​

mutate works on the response object ({ tag, command, attributes }) instead of bytes, so the result is still valid IMAP as far as the compiler can tell. The object is a copy: a notification that goes to several sessions changes only for the session the rule matched.

// every SELECT reports 5 messages, whatever the mailbox holds
server.script.add({
on: 'response',
command: 'SELECT',
match: /EXISTS/,
mutate: response => {
response.attributes[0] = 5;
}
});

If the changed response does not compile, a tagged response is sent as NO [SERVERBUG] Failed to compile response and an untagged one is dropped. Use send for output that is not valid IMAP. Continuation requests have a response object only when a plugin sends them with connection.send() (the error challenges of XOAUTH2 and OAUTHBEARER), for other continuations mutate does nothing.

literals​

literals: true turns every string of a response into a literal wherever the grammar allows one (string, nstring and astring in RFC 9051 section 9). This is valid IMAP that many clients still get wrong. Positions that take only a quoted string stay quoted:

  • the hierarchy delimiter of LIST, LSUB and NAMESPACE
  • CHILDINFO values (RFC 5258 section 6)
  • INTERNALDATE and SAVEDATE
  • the media types "TEXT" and "MESSAGE" "RFC822" (or "GLOBAL") in a body structure

Atoms, numbers, NIL, response codes and human readable text stay as they are. literals works together with mutate, after it.

defer​

defer needs untagged: true, since a tagged response ends its command and there is nothing to hold it back for. send, before and after change the held output. drop, delay, chunk, truncate and close can not be combined with it.

A response that does not belong to a command (one that arrives during IDLE belongs to IDLE) is held for the next tagged response or command. Held responses are dropped when the connection closes.

Rules for commands and input lines​

  • send replaces the processing of the line. Add run: true to process the line as usual after the bytes.
  • A rule with only delay (and run) delays the line and then processes it as usual.
  • chunk and truncate need send, since a command or input line has no output of its own to cut.
  • drop can not be combined with send or run, and run can not be combined with close or truncate.

The event context​

when, mutate and the function form of send, before and after get the event context:

FieldValue
eventthe event name
connectionthe IMAPConnection of the session
sessionthe connection number, 1 for the first one
statethe session state
userthe authenticated user, or null
mailboxthe path of the selected mailbox, or null
tagthe tag of the command the event belongs to, or null
commandthe upper case name of that command ('UID FETCH'), or null
datathe command or input line, or the bytes about to be sent, as a binary string
descriptionresponse and continuation: the description passed to connection.send()
responseresponse (and continuation sent with connection.send()): the response object
quietquiet: milliseconds without input or output
server.script.add({
on: 'response',
command: 'NOOP',
untagged: false,
send: context => context.tag + ' OK Žluťoučký\r\n' // characters above U+00FF, sent as UTF-8
});

Finding descriptions​

The description matcher compares the name the server gives each response internally. To see them, add a rule whose when logs the context and returns false. It never fires, so it changes nothing (after: '' is only there because a rule needs an action):

server.script.add([
{
on: 'response',
when: ctx => (console.log(ctx.command, ctx.description, JSON.stringify(ctx.data)), false),
after: ''
},
{ on: 'continuation', when: ctx => (console.log(ctx.command, ctx.description), false), after: '' }
]);

For a LOGIN, SELECT, FETCH, IDLE and LOGOUT this printed:

LOGIN LOGIN SUCCESS "A1 OK User logged in\r\n"
SELECT SELECT FLAGS "* FLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen)\r\n"
SELECT SELECT PERMANENTFLAGS "* OK [PERMANENTFLAGS ..."
SELECT SELECT EXISTS "* 1 EXISTS\r\n"
SELECT SELECT RECENT "* 0 RECENT\r\n"
SELECT SELECT UNSEEN "* OK [UNSEEN 1] First unseen message\r\n"
SELECT SELECT UIDVALIDITY "* OK [UIDVALIDITY 1] UIDs valid\r\n"
SELECT SELECT UIDNEXT "* OK [UIDNEXT 2] Predicted next UID\r\n"
SELECT SELECT "A2 OK [READ-WRITE] Completed\r\n"
FETCH FETCH "* 1 FETCH (FLAGS ())\r\n"
FETCH FETCH "A3 OK FETCH Completed\r\n"
IDLE IDLE
IDLE IDLE "A4 OK IDLE terminated\r\n"
LOGOUT LOGOUT UNTAGGED "* BYE LOGOUT received\r\n"
LOGOUT LOGOUT COMPLETED "A5 OK Completed\r\n"

The continuation requests are LITERAL, IDLE, AUTHENTICATE PLAIN and AUTHENTICATE OAUTHBEARER, and the error challenges AUTHENTICATE XOAUTH2 FAILED and AUTHENTICATE OAUTHBEARER CHALLENGE.

The script event​

The server emits a script event every time a rule fires, with { rule, event, session, tag, command }:

server.on('script', ({ rule, event, session, tag, command }) => {
console.log('rule fired', event, session, tag, command);
});

The REST event stream carries it too (GET /v1/events?types=script):

event: script
data: {"rule":{"on":"command","command":"NOOP","times":1,"send":"$TAG NO [UNAVAILABLE] Not now\r\n"},"event":"command","session":3,"tag":"A1","command":"NOOP"}

JSON rules: the command line and the REST API​

The imapkit command reads rules as JSON from --script=<path> (or the IMAPKIT_SCRIPT environment variable), or from script in the --config file. In JSON, match is a string with a regular expression and send, before and after are strings. Functions (when, mutate, and function values of send) work only from JavaScript.

faults.json
[
{ "on": "greeting", "send": "* BYE Too many connections\r\n", "close": true, "times": 1 },
{ "on": "response", "command": "FETCH", "untagged": true, "send": "* 1 FETCH (BODY[] {100}\r\nshort", "close": true }
]
imapkit -p 1143 --rest-port=8143 --script=faults.json

The first connection is turned away, the second one gets a literal that announces 100 octets and delivers 5:

S: * BYE Too many connections
[connection closed]
S: * OK ImapKit ready for rumble
C: A1 LOGIN testuser testpass
S: A1 OK User logged in
C: A2 SELECT INBOX
S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen)
S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted
S: * 1 EXISTS
S: * 0 RECENT
S: * OK [UNSEEN 1] First unseen message
S: * OK [UIDVALIDITY 1] UIDs valid
S: * OK [UIDNEXT 2] Predicted next UID
S: A2 OK [READ-WRITE] Completed
C: A3 FETCH 1 BODY[]
S: * 1 FETCH (BODY[] {100}
S: short [no CRLF]
[connection closed]

With the REST API on, rules in the same JSON form can be listed, added and removed at runtime, without a restart:

EndpointDoes
GET /v1/script/ruleslists the rules as { id, rule, matched, hits }
POST /v1/script/rulesadds a rule or a list of rules, answers 201 with the same shape
DELETE /v1/script/rulesremoves every rule
DELETE /v1/script/rules/{id}removes one rule, 404 with NONEXISTENT if there is no such rule
curl -X POST http://127.0.0.1:8143/v1/script/rules \
-H 'Content-Type: application/json' \
-d '{"on":"command","command":"NOOP","times":1,"send":"$TAG NO [UNAVAILABLE] Not now\r\n"}'
{ "id": 3, "rule": { "on": "command", "command": "NOOP", "times": 1, "send": "$TAG NO [UNAVAILABLE] Not now\r\n" }, "matched": 0, "hits": 0 }

An invalid rule is answered with 400:

{ "error": { "code": "INVALID", "message": "Unknown script rule option \"comand\"" } }

Validation​

Rules are checked when they are added. A mistake throws a TypeError right away (or fails the server constructor, for the script option), so a typo can not turn into a rule that silently never fires:

RuleError
{ on: 'response', command: 'FETCH', untagged: true, dorp: true }Unknown script rule option "dorp"
{ on: 'greeting', command: 'LOGIN', drop: true }Script rule option "command" can not be used with "on": "greeting"
{ on: 'response', command: 'FETCH' }A script rule needs an action (mutate, literals, defer, send, before, after, run, drop, delay, chunk, truncate, close)
{ on: 'response', defer: 'tagged' }Script rule option "defer" needs "untagged": true
{ on: 'quiet', send: 'x' }A quiet rule needs "quietFor", a positive number of milliseconds
{ on: 'command', chunk: 5 }Script rule options "chunk" and "truncate" need "send" with "on": "command"
{ on: 'response', match: '(', drop: true }Script rule option "match" is not a valid regular expression: Invalid regular expression: /(/: Unterminated group
{ on: 'response', chance: 2, drop: true }Script rule option "chance" must be a number from 0 to 1

Other checks: nth, times and chunk must be positive integers, delay, chunkDelay and truncate non-negative integers (chunkDelay may also be 'tick'), chunkDelay needs chunk, close is true, false or 'reset', defer is 'tagged' or 'next', and literals is true or false.

Cookbook​

Every transcript below comes from a real run against ImapKit, read with a raw socket client. C: lines are what the client sent, S: lines what the server sent, [no CRLF] marks output that ends in the middle of a line. The examples use this storage:

const storage = {
INBOX: {
messages: [
{ uid: 1, raw: 'From: alice@example.com\r\nSubject: hello\r\n\r\nHello world!\r\n' },
{ uid: 2, raw: 'From: bob@example.com\r\nSubject: again\r\n\r\nSecond message\r\n' }
]
}
};

NO on the first SELECT​

Does the client retry, or give up and report the mailbox as broken? times: 1 makes the second SELECT run as usual. UNAVAILABLE is a response code of RFC 5530.

{ on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' }
S: * OK ImapKit ready for rumble
C: A1 LOGIN testuser testpass
S: A1 OK User logged in
C: A2 SELECT INBOX
S: A2 NO [UNAVAILABLE] Try again later
C: A3 SELECT INBOX
S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen)
S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted
S: * 2 EXISTS
S: * 0 RECENT
S: * OK [UNSEEN 1] First unseen message
S: * OK [UIDVALIDITY 1] UIDs valid
S: * OK [UIDNEXT 3] Predicted next UID
S: A3 OK [READ-WRITE] Completed
C: A4 LOGOUT
S: * BYE LOGOUT received
S: A4 OK Completed
[connection closed]

A FETCH body cut in the middle of a literal​

The literal announces 57 octets, the connection closes after 16 of them. The client must report an error, not store a partial message.

{ on: 'response', command: 'FETCH', match: /^\* 1 FETCH .*BODY\[\]/, truncate: 40 }
C: A3 FETCH 1 BODY.PEEK[]
S: * 1 FETCH (BODY[] {57}
S: From: alice@exam [no CRLF]
[connection closed]

A FETCH response after the tagged OK​

Some servers now and then send an untagged FETCH after the tagged OK of its command. A client that collects FETCH data only until the tagged response loses the message.

{ on: 'response', command: 'UID FETCH', untagged: true, match: /UID 2\b/, times: 1, defer: 'tagged' }
C: A3 UID FETCH 1:2 (FLAGS)
S: * 1 FETCH (FLAGS () UID 1)
S: A3 OK UID FETCH Completed
S: * 2 FETCH (FLAGS () UID 2)
C: A4 LOGOUT
S: * BYE LOGOUT received
S: A4 OK Completed
[connection closed]

With defer: 'next' the held response goes out with the answer to the next command instead:

{ on: 'response', command: 'FETCH', untagged: true, match: /^\* 2 FETCH/, defer: 'next' }
C: A3 FETCH 1:2 (FLAGS)
S: * 1 FETCH (FLAGS ())
S: A3 OK FETCH Completed
C: A4 NOOP
S: * 2 FETCH (FLAGS ())
S: A4 OK Completed
C: A5 LOGOUT
S: * BYE LOGOUT received
S: A5 OK Completed
[connection closed]

Literals everywhere​

Valid IMAP that trips up clients which expect quoted strings. The LIST delimiter stays quoted, as the grammar requires.

{ on: 'response', untagged: true, literals: true }
S: * OK ImapKit ready for rumble
C: A1 LOGIN testuser testpass
S: A1 OK User logged in
C: A2 LIST "" "*"
S: * LIST (\HasNoChildren) "/" {5}
S: INBOX
S: A2 OK Completed
C: A3 SELECT INBOX
S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen)
S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted
S: * 2 EXISTS
S: * 0 RECENT
S: * OK [UNSEEN 1] First unseen message
S: * OK [UIDVALIDITY 1] UIDs valid
S: * OK [UIDNEXT 3] Predicted next UID
S: A3 OK [READ-WRITE] Completed
C: A4 FETCH 1 (ENVELOPE)
S: * 1 FETCH (ENVELOPE (NIL {5}
S: hello ((NIL NIL {5}
S: alice {11}
S: example.com)) ((NIL NIL {5}
S: alice {11}
S: example.com)) ((NIL NIL {5}
S: alice {11}
S: example.com)) NIL NIL NIL NIL NIL))
S: A4 OK FETCH Completed
C: A5 LOGOUT
S: * BYE LOGOUT received
S: A5 OK Completed
[connection closed]

Output split into pieces​

A client that assumes one read holds one response, or a whole literal, breaks when the output arrives in small pieces. Here the FETCH response leaves in 16 octet pieces, 50 ms apart. The times are measured from the moment the command was sent:

{ on: 'response', command: 'FETCH', untagged: true, chunk: 16, chunkDelay: 50 }
1 ms "* 1 FETCH (BODY["
51 ms "HEADER.FIELDS (S"
101 ms "UBJECT)] {18}\r\nS"
152 ms "ubject: hello\r\n\r"
202 ms "\n)\r\n"
202 ms "A3 OK FETCH Completed\r\n"

The tagged OK waited behind the pieces, all later output keeps its order. With chunkDelay: 'tick' (or 0) the pieces go out on separate event loop turns without a wall clock delay. On loopback the receiver may still merge a few of them into one read:

0 ms "* 1 FETCH (BODY[HEADER.FIELDS (S"
0 ms "UBJECT)] {18}\r\nS"
0 ms "ubject: hello\r\n\r"
0 ms "\n)\r\n"
0 ms "A3 OK FETCH Completed\r\n"

Autologout during IDLE​

RFC 3501 section 5.4 allows an inactivity autologout of at least 30 minutes, and section 7.1.5 shows the BYE it announces. A client that idles must re-issue IDLE before that, and handle the BYE when it comes. A real test would use quietFor: 1800000, this run uses 2 seconds:

{ on: 'quiet', command: 'IDLE', quietFor: 2000, send: '* BYE Autologout; idle for too long\r\n', close: true }
C: A3 IDLE
S: + idling
S: * BYE Autologout; idle for too long
[connection closed]

The connection closed 2003 ms after IDLE was sent.

An ALERT between commands​

Unsolicited output while no command is in progress (RFC 3501 section 5.3):

{ on: 'quiet', state: 'Selected', quietFor: 500, times: 1, send: '* OK [ALERT] System shutdown in 10 minutes\r\n' }
C: A2 SELECT INBOX
S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen)
S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted
S: * 2 EXISTS
S: * 0 RECENT
S: * OK [UNSEEN 1] First unseen message
S: * OK [UIDVALIDITY 1] UIDs valid
S: * OK [UIDNEXT 3] Predicted next UID
S: A2 OK [READ-WRITE] Completed
S: * OK [ALERT] System shutdown in 10 minutes
C: A3 NOOP
S: A3 OK Completed

When several quiet rules wait for different times, a rule that does not match lets the session wait on for the next longer quietFor.

Random throttling​

chance makes a rule fire on some matching events only. With scriptSeed the same client gets the same answers in every run. With seed 42 and a chance of 0.3, NOOPs 5 and 7 were refused:

const server = imapkit({
scriptSeed: 42,
script: { on: 'command', command: 'NOOP', chance: 0.3, send: '$TAG NO [LIMIT] Too many commands, slow down\r\n' }
});
C: A2 NOOP
S: A2 OK Completed
C: A3 NOOP
S: A3 OK Completed
C: A4 NOOP
S: A4 OK Completed
C: A5 NOOP
S: A5 OK Completed
C: A6 NOOP
S: A6 NO [LIMIT] Too many commands, slow down
C: A7 NOOP
S: A7 OK Completed
C: A8 NOOP
S: A8 NO [LIMIT] Too many commands, slow down
C: A9 NOOP
S: A9 OK Completed

The m365-throttle quirk preset does the same for every command, with the text Microsoft 365 sends.

A server that ignores DONE​

The first DONE is dropped, the server keeps idling until the client sends another one. A client that waits forever for the tagged OK of IDLE hangs here, a careful one times out.

{ on: 'input', command: 'IDLE', match: /^DONE$/, times: 1, drop: true }
C: A3 IDLE
S: + idling
C: DONE
C: DONE
S: A3 OK IDLE terminated

An unexpected EXISTS before a tagged response​

before adds output in front of a response. Here every NOOP reports a third message that does not exist, a client must not fetch it blindly.

{ on: 'response', command: 'NOOP', untagged: false, before: '* 3 EXISTS\r\n' }
C: A3 NOOP
S: * 3 EXISTS
S: A3 OK Completed

A connection reset​

close: 'reset' destroys the socket, the client sees ECONNRESET instead of an orderly close:

{ on: 'command', command: 'FETCH', close: 'reset' }
C: A3 FETCH 1:* (FLAGS)
[connection closed with ECONNRESET]

Bun and Deno may close such a connection without a RST.