Skip to content

Commit 9a2cf4c

Browse files
committed
fix: console.log() writes to RPC channel
Problem: 2d47447 removed "sandboxing" and accidentally also removed the console.log monkey-patching. This means a random call to `console.log` can write to stdout, which breaks the RPC channel. Solution: Re-implement the patching. fix #202 fix #329
1 parent ab7e260 commit 9a2cf4c

File tree

5 files changed

+147
-63
lines changed

5 files changed

+147
-63
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ on:
1010

1111
env:
1212
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
13+
# Purpose of this (currently) is to exercise related codepaths.
14+
NVIM_NODE_LOG_FILE: 'node-client.test.log'
1315

1416
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
1517
jobs:

README.md

Lines changed: 70 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,19 @@ See below for a quickstart example that you can copy and run immediately.
2121

2222
The `neovim` package provides these functions:
2323

24-
- `findNvim`: Tries to find a usable `nvim` binary on the current system.
25-
- `attach`: The primary interface. Takes a process, socket, or pair of write/read streams and returns a `NeovimClient` connected to an `nvim` process.
24+
- `attach()`: The primary interface. Takes a process, socket, or pair of write/read streams and returns a `NeovimClient` connected to an `nvim` process.
25+
- `findNvim()`: Tries to find a usable `nvim` binary on the current system.
26+
27+
### Logging
28+
29+
- At load-time, the `neovim` module replaces ("monkey patches") `console` with its `logger`
30+
interface, so `console.log` will call `logger.info` instead of writing to stdout (which would
31+
break the stdio RPC channel).
32+
- To skip this patching of `console.log`, pass a custom `logger` to `attach()`.
33+
- Best practice in any case is to use the `logger` available from the `NeovimClient` returned by
34+
`attach()`, instead of `console` logging functions.
35+
- Set the `$NVIM_NODE_LOG_FILE` env var to (also) write logs to a file.
36+
- Set the `$ALLOW_CONSOLE` env var to (also) write logs to stdout.
2637

2738
### Quickstart: connect to Nvim
2839

@@ -34,53 +45,54 @@ Following is a complete, working example.
3445
```
3546
2. Paste the script below into a `demo.mjs` file and run it!
3647
```
37-
node demo.mjs
48+
ALLOW_CONSOLE=1 node demo.mjs
3849
```
39-
40-
```js
41-
import * as child_process from 'node:child_process'
42-
import * as assert from 'node:assert'
43-
import { attach, findNvim } from 'neovim'
44-
45-
// Find `nvim` on the system and open a channel to it.
46-
(async function() {
47-
const found = findNvim({ orderBy: 'desc', minVersion: '0.9.0' })
48-
console.log(found);
49-
const nvim_proc = child_process.spawn(found.matches[0].path, ['--clean', '--embed'], {});
50-
51-
const nvim = attach({ proc: nvim_proc });
52-
nvim.command('vsp | vsp | vsp');
53-
54-
const windows = await nvim.windows;
55-
assert.deepStrictEqual(windows.length, 4);
56-
assert.ok(windows[0] instanceof nvim.Window);
57-
58-
nvim.window = windows[2];
59-
const win = await nvim.window;
60-
assert.ok(win.id !== windows[0].id);
61-
assert.deepStrictEqual(win.id, windows[2].id);
62-
63-
const buf = await nvim.buffer;
64-
assert.ok(buf instanceof nvim.Buffer);
65-
const lines = await buf.lines;
66-
assert.deepStrictEqual(lines, []);
67-
68-
await buf.replace(['line1', 'line2'], 0);
69-
const newLines = await buf.lines;
70-
assert.deepStrictEqual(newLines, ['line1', 'line2']);
71-
72-
// console.log('%O', nvim_proc);
73-
if (nvim_proc.disconnect) {
74-
nvim_proc.disconnect();
75-
}
76-
nvim.quit();
77-
while (nvim_proc.exitCode === null) {
78-
await new Promise(resolve => setTimeout(resolve, 100))
79-
console.log('waiting for Nvim (pid %d) to exit', nvim_proc.pid);
80-
}
81-
console.log('Nvim exit code: %d', nvim_proc.exitCode);
82-
})();
83-
```
50+
- `$ALLOW_CONSOLE` env var must be set, because logs are normally not printed to stdout.
51+
- Script:
52+
```js
53+
import * as child_process from 'node:child_process'
54+
import * as assert from 'node:assert'
55+
import { attach, findNvim } from 'neovim'
56+
57+
// Find `nvim` on the system and open a channel to it.
58+
(async function() {
59+
const found = findNvim({ orderBy: 'desc', minVersion: '0.9.0' })
60+
console.log(found);
61+
const nvim_proc = child_process.spawn(found.matches[0].path, ['--clean', '--embed'], {});
62+
63+
const nvim = attach({ proc: nvim_proc });
64+
nvim.command('vsp | vsp | vsp');
65+
66+
const windows = await nvim.windows;
67+
assert.deepStrictEqual(windows.length, 4);
68+
assert.ok(windows[0] instanceof nvim.Window);
69+
70+
nvim.window = windows[2];
71+
const win = await nvim.window;
72+
assert.ok(win.id !== windows[0].id);
73+
assert.deepStrictEqual(win.id, windows[2].id);
74+
75+
const buf = await nvim.buffer;
76+
assert.ok(buf instanceof nvim.Buffer);
77+
const lines = await buf.lines;
78+
assert.deepStrictEqual(lines, []);
79+
80+
await buf.replace(['line1', 'line2'], 0);
81+
const newLines = await buf.lines;
82+
assert.deepStrictEqual(newLines, ['line1', 'line2']);
83+
84+
// console.log('%O', nvim_proc);
85+
if (nvim_proc.disconnect) {
86+
nvim_proc.disconnect();
87+
}
88+
nvim.quit();
89+
while (nvim_proc.exitCode === null) {
90+
await new Promise(resolve => setTimeout(resolve, 100))
91+
console.log('waiting for Nvim (pid %d) to exit', nvim_proc.pid);
92+
}
93+
console.log('Nvim exit code: %d', nvim_proc.exitCode);
94+
})();
95+
```
8496

8597
### Create a remote plugin
8698

@@ -94,8 +106,6 @@ The plugin must export a function which takes a `NvimPlugin` object as its only
94106
**Avoid heavy initialisation or async functions at this stage,** because Nvim may only be collecting information about your plugin without wishing to actually use it.
95107
Instead, wait for one of your autocmds, commands or functions to be called before starting any processing.
96108
97-
The host replaces `console` with a `winston` interface, so `console.log` will call `winston.info`.
98-
99109
### Remote plugin examples
100110
101111
See [`examples/`](https://github.com/neovim/node-client/tree/master/examples) for remote plugin examples.
@@ -169,7 +179,7 @@ For debugging and configuring logging, you can set the following environment var
169179
170180
- `NVIM_NODE_HOST_DEBUG`: Spawns the node process that calls `neovim-client-host` with `--inspect-brk` so you can have a debugger.
171181
Pair that with this [Node Inspector Manager Chrome plugin](https://chrome.google.com/webstore/detail/nodejs-v8-inspector-manag/gnhhdgbaldcilmgcpfddgdbkhjohddkj?hl=en)
172-
- Logging: Logging is done using `winston` through the `logger` module. Plugins have `console` replaced with this interface.
182+
- Logging: Logging is done using `winston` through the `logger` module. This package replaces `console` with this interface.
173183
- `NVIM_NODE_LOG_LEVEL`: Sets the logging level for winston. Default is `debug`.
174184
Available levels: `{ error: 0, warn: 1, info: 2, verbose: 3, debug: 4, silly: 5 }`
175185
- `NVIM_NODE_LOG_FILE`: Sets the log file path.
@@ -190,6 +200,14 @@ For debugging and configuring logging, you can set the following environment var
190200
191201
See the tests and [`scripts`](https://github.com/neovim/node-client/tree/master/packages/neovim/scripts) for more examples.
192202
203+
## Develop
204+
205+
After cloning the repo, run `npm install` to install dev dependencies. The main `neovim` library is in `packages/neovim`.
206+
207+
### Run tests
208+
209+
npm run build && NVIM_NODE_LOG_FILE=log npm run test
210+
193211
## Maintain
194212
195213
### Release
@@ -232,11 +250,7 @@ git commit -m 'publish docs'
232250
git push origin HEAD:gh-pages
233251
```
234252
235-
## Contributing
236-
237-
After cloning the repo, run `npm install` to install dev dependencies. The main `neovim` library is in `packages/neovim`.
238-
239-
### Contributors
253+
## Contributors
240254
241255
* [@billyvg](https://github.com/billyvg) for rewrite
242256
* [@mhartington](https://github.com/mhartington) for TypeScript rewrite

packages/integration-tests/__tests__/integration.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ describe('Node host', () => {
6565
// done();
6666
// });
6767

68+
it('console.log is monkey-patched to logger.info #329', async () => {
69+
const spy = jest.spyOn(nvim.logger, 'info');
70+
console.log('log message');
71+
expect(spy).toHaveBeenCalledWith('log message');
72+
// Still alive?
73+
expect(await nvim.eval('1+1')).toEqual(2);
74+
});
75+
6876
it('can run a command from plugin', async () => {
6977
await nvim.command('JSHostTestCmd');
7078
const line = await nvim.line;

packages/neovim/src/attach/attach.test.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import * as cp from 'node:child_process';
33
// eslint-disable-next-line import/no-extraneous-dependencies
44
import * as which from 'which';
55
import { attach } from './attach';
6+
import { Logger, logger } from '../utils/logger';
7+
import { NeovimClient } from '../api/client';
68

79
try {
810
which.sync('nvim');
@@ -15,17 +17,29 @@ try {
1517
process.exit(1);
1618
}
1719

18-
describe('Nvim Promise API', () => {
20+
describe('Nvim API', () => {
1921
let proc;
2022
let nvim;
2123
let requests;
2224
let notifications;
2325

26+
function startNvim() {
27+
return cp.spawn('nvim', ['-u', 'NONE', '--embed', '-n', '--noplugin'], {
28+
cwd: __dirname,
29+
});
30+
}
31+
32+
function stopNvim(proc_: cp.ChildProcessWithoutNullStreams | NeovimClient) {
33+
if (proc_ instanceof NeovimClient) {
34+
proc_.command('qa!');
35+
} else if (proc_ && proc_.connected) {
36+
proc_.disconnect();
37+
}
38+
}
39+
2440
beforeAll(async () => {
2541
try {
26-
proc = cp.spawn('nvim', ['-u', 'NONE', '--embed', '-n', '--noplugin'], {
27-
cwd: __dirname,
28-
});
42+
proc = startNvim();
2943

3044
nvim = attach({ proc });
3145
nvim.on('request', (method, args, resp) => {
@@ -42,16 +56,55 @@ describe('Nvim Promise API', () => {
4256
});
4357

4458
afterAll(() => {
45-
if (proc && proc.connected) {
46-
proc.disconnect();
47-
}
59+
stopNvim(nvim);
60+
stopNvim(proc);
4861
});
4962

5063
beforeEach(() => {
5164
requests = [];
5265
notifications = [];
5366
});
5467

68+
it('console.log is monkey-patched to logger.info #329', async () => {
69+
const spy = jest.spyOn(nvim.logger, 'info');
70+
console.log('log message');
71+
expect(spy).toHaveBeenCalledWith('log message');
72+
// Still alive?
73+
expect(await nvim.eval('1+1')).toEqual(2);
74+
});
75+
76+
it('console.log NOT monkey-patched if custom logger passed to attach()', async () => {
77+
const proc2 = startNvim();
78+
const logged: string[] = [];
79+
const fakeLog = (msg: any) => {
80+
logged.push(msg);
81+
return logger2;
82+
}
83+
const logger2 = {
84+
info: fakeLog,
85+
warn: fakeLog,
86+
debug: fakeLog,
87+
error: fakeLog,
88+
} as Logger;
89+
const nvim2 = attach({ proc: proc2, options: { logger: logger2 } });
90+
91+
const spy = jest.spyOn(nvim2.logger, 'info');
92+
console.log('message 1');
93+
// console.log was NOT patched.
94+
expect(spy).toHaveBeenCalledTimes(0);
95+
// Custom logger did NOT get the message.
96+
expect(logged).toEqual([])
97+
98+
// Custom logger can be called explicitly.
99+
nvim2.logger.info('message 2')
100+
expect(logged).toEqual(['message 2'])
101+
102+
// Still alive?
103+
expect(await nvim2.eval('1+1')).toEqual(2);
104+
105+
stopNvim(nvim2);
106+
});
107+
55108
it('can send requests and receive response', async () => {
56109
const result = await nvim.eval('{"k1": "v1", "k2": 2}');
57110
expect(result).toEqual({ k1: 'v1', k2: 2 });

packages/neovim/src/utils/logger.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ function setupWinstonLogger(): Logger {
3131
logger.add(new winston.transports.Console({ silent: true }));
3232
}
3333

34+
// Monkey-patch `console` so that it does not write to the RPC (stdio) channel.
35+
Object.keys(console).forEach((k: keyof Console) => {
36+
(console as any)[k] = function () {
37+
(logger as any)[k === 'log' ? 'info' : k].apply(logger, arguments);
38+
};
39+
});
40+
3441
return logger;
3542
}
3643

0 commit comments

Comments
 (0)