resplite 1.1.4 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/package.json +1 -1
- package/src/commands/keys.js +67 -0
- package/src/commands/monitor.js +10 -0
- package/src/commands/registry.js +4 -0
- package/src/server/connection.js +15 -0
- package/src/server/monitor.js +59 -0
- package/tasks/todo.md +19 -0
- package/test/integration/keys.test.js +49 -0
- package/test/integration/monitor.test.js +102 -0
package/README.md
CHANGED
|
@@ -29,17 +29,19 @@ A typical comparison is **Redis (e.g. in Docker)** on one side and **RESPLite lo
|
|
|
29
29
|
|
|
30
30
|
| Suite | Redis (Docker) | RESPLite (default) |
|
|
31
31
|
|-----------------|----------------|--------------------|
|
|
32
|
-
| PING |
|
|
33
|
-
| SET+GET | 4.
|
|
34
|
-
| MSET+MGET(10) | 4.
|
|
35
|
-
| INCR | 9.
|
|
36
|
-
| HSET+HGET | 4.
|
|
37
|
-
| HGETALL(50) |
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
32
|
+
| PING | 8.79K/s | 37.36K/s |
|
|
33
|
+
| SET+GET | 4.68K/s | 11.96K/s |
|
|
34
|
+
| MSET+MGET(10) | 4.41K/s | 5.81K/s |
|
|
35
|
+
| INCR | 9.54K/s | 18.97K/s |
|
|
36
|
+
| HSET+HGET | 4.40K/s | 11.91K/s |
|
|
37
|
+
| HGETALL(50) | 8.39K/s | 11.01K/s |
|
|
38
|
+
| HLEN(50) | 9.36K/s | 31.21K/s |
|
|
39
|
+
| SADD+SMEMBERS | 9.27K/s | 17.37K/s |
|
|
40
|
+
| LPUSH+LRANGE | 8.34K/s | 14.27K/s |
|
|
41
|
+
| LREM | 4.37K/s | 6.08K/s |
|
|
42
|
+
| ZADD+ZRANGE | 7.80K/s | 17.12K/s |
|
|
43
|
+
| SET+DEL | 4.39K/s | 9.57K/s |
|
|
44
|
+
| FT.SEARCH | 8.36K/s | 8.22K/s |
|
|
43
45
|
|
|
44
46
|
*Run `npm run benchmark -- --template default` to reproduce. Numbers depend on host and whether Redis is native or in Docker.*
|
|
45
47
|
|
|
@@ -327,7 +329,7 @@ RESPLite implements **47 core Redis commands** (~19% of the ~246 commands in Red
|
|
|
327
329
|
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, BLPOP, BRPOP |
|
|
328
330
|
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZRANGEBYSCORE |
|
|
329
331
|
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
330
|
-
| **Introspection** | TYPE, SCAN |
|
|
332
|
+
| **Introspection** | TYPE, SCAN, KEYS, MONITOR |
|
|
331
333
|
| **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
|
|
332
334
|
| **Tooling** | Redis import CLI (see Migration from Redis) |
|
|
333
335
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KEYS pattern - return all keys matching the glob pattern.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function escapeRegexChar(ch) {
|
|
6
|
+
return /[\\^$+?.()|{}]/.test(ch) ? '\\' + ch : ch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function globToRegExp(glob) {
|
|
10
|
+
let out = '^';
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < glob.length) {
|
|
13
|
+
const ch = glob[i];
|
|
14
|
+
if (ch === '\\') {
|
|
15
|
+
if (i + 1 < glob.length) {
|
|
16
|
+
out += escapeRegexChar(glob[i + 1]);
|
|
17
|
+
i += 2;
|
|
18
|
+
} else {
|
|
19
|
+
out += '\\\\';
|
|
20
|
+
i += 1;
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (ch === '*') {
|
|
25
|
+
out += '.*';
|
|
26
|
+
i += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === '?') {
|
|
30
|
+
out += '.';
|
|
31
|
+
i += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
out += escapeRegexChar(ch);
|
|
35
|
+
i += 1;
|
|
36
|
+
}
|
|
37
|
+
out += '$';
|
|
38
|
+
return new RegExp(out);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function handleKeys(engine, args) {
|
|
42
|
+
if (!args || args.length !== 1) {
|
|
43
|
+
return { error: 'ERR wrong number of arguments for \'KEYS\' command' };
|
|
44
|
+
}
|
|
45
|
+
const pattern = args[0].toString('utf8');
|
|
46
|
+
const matcher = globToRegExp(pattern);
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const matches = [];
|
|
49
|
+
|
|
50
|
+
let cursor = '0';
|
|
51
|
+
do {
|
|
52
|
+
const scanned = engine.scan(cursor, { count: 256 });
|
|
53
|
+
const keys = scanned.keys ?? [];
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
if (!Buffer.isBuffer(key)) continue;
|
|
56
|
+
const hex = key.toString('hex');
|
|
57
|
+
if (seen.has(hex)) continue;
|
|
58
|
+
seen.add(hex);
|
|
59
|
+
if (engine.type(key) === 'none') continue;
|
|
60
|
+
const keyText = key.toString('utf8');
|
|
61
|
+
if (matcher.test(keyText)) matches.push(key);
|
|
62
|
+
}
|
|
63
|
+
cursor = String(scanned.cursor);
|
|
64
|
+
} while (cursor !== '0');
|
|
65
|
+
|
|
66
|
+
return matches;
|
|
67
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -46,6 +46,7 @@ import * as lrem from './lrem.js';
|
|
|
46
46
|
import * as blpop from './blpop.js';
|
|
47
47
|
import * as brpop from './brpop.js';
|
|
48
48
|
import * as scan from './scan.js';
|
|
49
|
+
import * as keys from './keys.js';
|
|
49
50
|
import * as zadd from './zadd.js';
|
|
50
51
|
import * as zrem from './zrem.js';
|
|
51
52
|
import * as zcard from './zcard.js';
|
|
@@ -63,6 +64,7 @@ import * as ftSearch from './ft-search.js';
|
|
|
63
64
|
import * as ftSugadd from './ft-sugadd.js';
|
|
64
65
|
import * as ftSugget from './ft-sugget.js';
|
|
65
66
|
import * as ftSugdel from './ft-sugdel.js';
|
|
67
|
+
import * as monitor from './monitor.js';
|
|
66
68
|
|
|
67
69
|
const HANDLERS = new Map([
|
|
68
70
|
['PING', (e, a) => ping.handlePing()],
|
|
@@ -108,6 +110,7 @@ const HANDLERS = new Map([
|
|
|
108
110
|
['BLPOP', (e, a, ctx) => blpop.handleBlpop(e, a, ctx)],
|
|
109
111
|
['BRPOP', (e, a, ctx) => brpop.handleBrpop(e, a, ctx)],
|
|
110
112
|
['SCAN', (e, a) => scan.handleScan(e, a)],
|
|
113
|
+
['KEYS', (e, a) => keys.handleKeys(e, a)],
|
|
111
114
|
['ZADD', (e, a) => zadd.handleZadd(e, a)],
|
|
112
115
|
['ZREM', (e, a) => zrem.handleZrem(e, a)],
|
|
113
116
|
['ZCARD', (e, a) => zcard.handleZcard(e, a)],
|
|
@@ -125,6 +128,7 @@ const HANDLERS = new Map([
|
|
|
125
128
|
['FT.SUGADD', (e, a) => ftSugadd.handleFtSugadd(e, a)],
|
|
126
129
|
['FT.SUGGET', (e, a) => ftSugget.handleFtSugget(e, a)],
|
|
127
130
|
['FT.SUGDEL', (e, a) => ftSugdel.handleFtSugdel(e, a)],
|
|
131
|
+
['MONITOR', (e, a, ctx) => monitor.handleMonitor(a, ctx)],
|
|
128
132
|
]);
|
|
129
133
|
|
|
130
134
|
/**
|
package/src/server/connection.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { RESPReader } from '../resp/parser.js';
|
|
6
6
|
import { dispatch } from '../commands/registry.js';
|
|
7
7
|
import { encode, encodeSimpleString, encodeError } from '../resp/encoder.js';
|
|
8
|
+
import { registerMonitorClient, unregisterMonitorClient, broadcastMonitorCommand } from './monitor.js';
|
|
8
9
|
|
|
9
10
|
let nextConnectionId = 0;
|
|
10
11
|
|
|
@@ -17,6 +18,8 @@ export function handleConnection(socket, engine) {
|
|
|
17
18
|
const connectionId = ++nextConnectionId;
|
|
18
19
|
const context = {
|
|
19
20
|
connectionId,
|
|
21
|
+
monitorMode: false,
|
|
22
|
+
clientAddress: `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`,
|
|
20
23
|
writeResponse(buf) {
|
|
21
24
|
if (socket.writable) socket.write(buf);
|
|
22
25
|
},
|
|
@@ -46,7 +49,18 @@ export function handleConnection(socket, engine) {
|
|
|
46
49
|
reader.feed(chunk);
|
|
47
50
|
const commands = reader.parseCommands();
|
|
48
51
|
for (const argv of commands) {
|
|
52
|
+
const cmd = argv[0] ? argv[0].toString('utf8').toUpperCase() : '';
|
|
53
|
+
if (context.monitorMode && cmd !== 'QUIT') {
|
|
54
|
+
socket.write(encodeError('ERR MONITOR mode only supports QUIT'));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
49
57
|
const out = dispatch(engine, argv, context);
|
|
58
|
+
if (cmd === 'MONITOR' && !out.error) {
|
|
59
|
+
context.monitorMode = true;
|
|
60
|
+
registerMonitorClient(context);
|
|
61
|
+
} else if (!context.monitorMode) {
|
|
62
|
+
broadcastMonitorCommand(argv, context);
|
|
63
|
+
}
|
|
50
64
|
if (out.quit) {
|
|
51
65
|
writeResult(out);
|
|
52
66
|
return;
|
|
@@ -77,6 +91,7 @@ export function handleConnection(socket, engine) {
|
|
|
77
91
|
|
|
78
92
|
socket.on('close', () => {
|
|
79
93
|
if (engine._blockingManager) engine._blockingManager.cancel(connectionId);
|
|
94
|
+
unregisterMonitorClient(connectionId);
|
|
80
95
|
});
|
|
81
96
|
|
|
82
97
|
socket.on('error', () => {});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MONITOR support: track monitor clients and broadcast command traces.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { encodeSimpleString } from '../resp/encoder.js';
|
|
6
|
+
|
|
7
|
+
const monitorClients = new Map();
|
|
8
|
+
|
|
9
|
+
function escapeArg(value) {
|
|
10
|
+
return value
|
|
11
|
+
.replace(/\\/g, '\\\\')
|
|
12
|
+
.replace(/"/g, '\\"')
|
|
13
|
+
.replace(/\r/g, '\\r')
|
|
14
|
+
.replace(/\n/g, '\\n')
|
|
15
|
+
.replace(/\t/g, '\\t');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatCommand(argv) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
for (const arg of argv) {
|
|
21
|
+
const text = Buffer.isBuffer(arg) ? arg.toString('utf8') : String(arg);
|
|
22
|
+
parts.push('"' + escapeArg(text) + '"');
|
|
23
|
+
}
|
|
24
|
+
return parts.join(' ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function timestamp() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const sec = Math.floor(now / 1000);
|
|
30
|
+
const micros = String((now % 1000) * 1000).padStart(6, '0');
|
|
31
|
+
return sec + '.' + micros;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sourceLabel(sourceContext) {
|
|
35
|
+
const label = sourceContext && sourceContext.clientAddress ? sourceContext.clientAddress : 'unknown';
|
|
36
|
+
return '[0 ' + label + ']';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerMonitorClient(context) {
|
|
40
|
+
if (!context || !context.connectionId) return;
|
|
41
|
+
monitorClients.set(context.connectionId, context);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unregisterMonitorClient(connectionId) {
|
|
45
|
+
if (!connectionId) return;
|
|
46
|
+
monitorClients.delete(connectionId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function broadcastMonitorCommand(argv, sourceContext) {
|
|
50
|
+
if (!Array.isArray(argv) || argv.length === 0) return;
|
|
51
|
+
if (monitorClients.size === 0) return;
|
|
52
|
+
const line = timestamp() + ' ' + sourceLabel(sourceContext) + ' ' + formatCommand(argv);
|
|
53
|
+
const payload = encodeSimpleString(line);
|
|
54
|
+
for (const monitor of monitorClients.values()) {
|
|
55
|
+
if (monitor && typeof monitor.writeResponse === 'function') {
|
|
56
|
+
monitor.writeResponse(payload);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/tasks/todo.md
CHANGED
|
@@ -42,3 +42,22 @@
|
|
|
42
42
|
- [x] package.json `bin`: resplite-import, resplite-dirty-tracker
|
|
43
43
|
- [x] Unit tests: `test/unit/migration-registry.test.js`
|
|
44
44
|
- [x] README: minimal-downtime migration flow and commands
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# KEYS + MONITOR Commands
|
|
49
|
+
|
|
50
|
+
## Plan
|
|
51
|
+
|
|
52
|
+
- [x] Add `KEYS pattern` command with glob matching support
|
|
53
|
+
- [x] Add `MONITOR` command and streaming monitor bus in TCP server
|
|
54
|
+
- [x] Register commands in command registry
|
|
55
|
+
- [x] Add integration tests for both commands
|
|
56
|
+
- [x] Verify with targeted and full integration suites
|
|
57
|
+
|
|
58
|
+
## Review
|
|
59
|
+
|
|
60
|
+
- Added `KEYS` command with wildcard matching (`*`, `?`) and proper arity validation
|
|
61
|
+
- Added server-side monitor broadcasting for `MONITOR` clients and restricted monitor-mode commands to `QUIT`
|
|
62
|
+
- Added integration coverage in `test/integration/keys.test.js` and `test/integration/monitor.test.js`
|
|
63
|
+
- Verified with `npm test -- test/integration/keys.test.js`, `npm test -- test/integration/monitor.test.js`, and `npm run test:integration`
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createTestServer } from '../helpers/server.js';
|
|
4
|
+
import { sendCommand, argv } from '../helpers/client.js';
|
|
5
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
6
|
+
|
|
7
|
+
describe('KEYS integration', () => {
|
|
8
|
+
let s;
|
|
9
|
+
let port;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
s = await createTestServer();
|
|
13
|
+
port = s.port;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
await s.closeAsync();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('KEYS * returns matching keys', async () => {
|
|
21
|
+
await sendCommand(port, argv('SET', 'keys:k1', 'v1'));
|
|
22
|
+
await sendCommand(port, argv('SET', 'keys:k2', 'v2'));
|
|
23
|
+
await sendCommand(port, argv('SET', 'other:k3', 'v3'));
|
|
24
|
+
|
|
25
|
+
const reply = await sendCommand(port, argv('KEYS', 'keys:*'));
|
|
26
|
+
const parsed = tryParseValue(reply, 0);
|
|
27
|
+
assert.ok(Array.isArray(parsed.value));
|
|
28
|
+
const values = parsed.value.map((v) => v.toString('utf8')).sort();
|
|
29
|
+
assert.deepEqual(values, ['keys:k1', 'keys:k2']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('KEYS with ? wildcard works', async () => {
|
|
33
|
+
await sendCommand(port, argv('SET', 'q:a1', 'x'));
|
|
34
|
+
await sendCommand(port, argv('SET', 'q:b2', 'x'));
|
|
35
|
+
await sendCommand(port, argv('SET', 'q:bb2', 'x'));
|
|
36
|
+
|
|
37
|
+
const reply = await sendCommand(port, argv('KEYS', 'q:?2'));
|
|
38
|
+
const parsed = tryParseValue(reply, 0);
|
|
39
|
+
const values = parsed.value.map((v) => v.toString('utf8')).sort();
|
|
40
|
+
assert.deepEqual(values, ['q:b2']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('KEYS wrong number of arguments returns error', async () => {
|
|
44
|
+
const reply = await sendCommand(port, argv('KEYS'));
|
|
45
|
+
const parsed = tryParseValue(reply, 0);
|
|
46
|
+
assert.ok(parsed.value && parsed.value.error);
|
|
47
|
+
assert.ok(parsed.value.error.includes('wrong number of arguments'));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { createTestServer } from '../helpers/server.js';
|
|
5
|
+
import { sendCommand, argv } from '../helpers/client.js';
|
|
6
|
+
import { encode } from '../../src/resp/encoder.js';
|
|
7
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
8
|
+
|
|
9
|
+
function createStreamingClient(port) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let recv = Buffer.alloc(0);
|
|
12
|
+
const queue = [];
|
|
13
|
+
const waiters = [];
|
|
14
|
+
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
|
|
15
|
+
resolve({
|
|
16
|
+
send(commandArgv) {
|
|
17
|
+
socket.write(encode(commandArgv));
|
|
18
|
+
},
|
|
19
|
+
async nextValue(timeoutMs = 2000) {
|
|
20
|
+
if (queue.length > 0) return queue.shift();
|
|
21
|
+
return new Promise((res, rej) => {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
const idx = waiters.findIndex((w) => w.resolve === res);
|
|
24
|
+
if (idx >= 0) waiters.splice(idx, 1);
|
|
25
|
+
rej(new Error('timeout waiting for RESP value'));
|
|
26
|
+
}, timeoutMs);
|
|
27
|
+
waiters.push({
|
|
28
|
+
resolve(value) {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
res(value);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
close() {
|
|
36
|
+
socket.destroy();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
socket.on('data', (chunk) => {
|
|
42
|
+
recv = Buffer.concat([recv, chunk]);
|
|
43
|
+
for (;;) {
|
|
44
|
+
const parsed = tryParseValue(recv, 0);
|
|
45
|
+
if (parsed === null) break;
|
|
46
|
+
recv = recv.subarray(parsed.end);
|
|
47
|
+
if (waiters.length > 0) {
|
|
48
|
+
waiters.shift().resolve(parsed.value);
|
|
49
|
+
} else {
|
|
50
|
+
queue.push(parsed.value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
socket.on('error', reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('MONITOR integration', () => {
|
|
59
|
+
let s;
|
|
60
|
+
let port;
|
|
61
|
+
|
|
62
|
+
before(async () => {
|
|
63
|
+
s = await createTestServer();
|
|
64
|
+
port = s.port;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
after(async () => {
|
|
68
|
+
await s.closeAsync();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('MONITOR streams commands from other clients', async () => {
|
|
72
|
+
const monitorClient = await createStreamingClient(port);
|
|
73
|
+
try {
|
|
74
|
+
monitorClient.send(argv('MONITOR'));
|
|
75
|
+
const first = await monitorClient.nextValue();
|
|
76
|
+
assert.equal(first, 'OK');
|
|
77
|
+
|
|
78
|
+
await sendCommand(port, argv('SET', 'mk1', 'v1'));
|
|
79
|
+
const event = await monitorClient.nextValue();
|
|
80
|
+
assert.equal(typeof event, 'string');
|
|
81
|
+
assert.match(event, /"SET"\s+"mk1"\s+"v1"/);
|
|
82
|
+
} finally {
|
|
83
|
+
monitorClient.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('MONITOR connection only accepts QUIT', async () => {
|
|
88
|
+
const monitorClient = await createStreamingClient(port);
|
|
89
|
+
try {
|
|
90
|
+
monitorClient.send(argv('MONITOR'));
|
|
91
|
+
assert.equal(await monitorClient.nextValue(), 'OK');
|
|
92
|
+
monitorClient.send(argv('PING'));
|
|
93
|
+
const err = await monitorClient.nextValue();
|
|
94
|
+
assert.ok(err && err.error);
|
|
95
|
+
assert.match(err.error, /MONITOR mode only supports QUIT/);
|
|
96
|
+
monitorClient.send(argv('QUIT'));
|
|
97
|
+
assert.equal(await monitorClient.nextValue(), 'OK');
|
|
98
|
+
} finally {
|
|
99
|
+
monitorClient.close();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|