Skip to content

Commit 2c7d950

Browse files
authored
fix(cli-repl): do not recursive push() in LineByLineInput MONGOSH-586 (#659)
First half of addressing MONGOSH-586. Before this commit, what could happen in blocked mode is that: - There is data that was entered on the input stream but not yet released - Ctrl+C was received - The handler for released data would do something that leads to another `_flush()` call - This inner `_flush()` call would start emitting data while we were already doing so, which is not something that the readline implementation expects from its input stream (which is arguably a bug in Node.js) Concretely, in the situation described in the ticket: 1. Autocompleting during `.editor` puts the stream into blocking mode (the other half of the ticket) 2. Pressing Ctrl+C to abort that means that the Ctrl+C character is forwarded immediately 3. The readline implementation reads it and emits its internal 'SIGINT' event 4. The REPL implementation uses `.displayPrompt()` when that event is received 5. Our `.displayPrompt()` handler explicitly calls `nextLine()` 6. `nextLine()` leads to another `_flush()` call, which would then start emitting queued up data while the `.push()` call for the `Ctrl+C` is still on the stack 7. The readline generator function for keypress events crashes because generators can’t have their `.next()` function called recursively In order to fix this, we do not process `._flush()` calls when we are already inside a `.push()` call. This should be a reasonable fix, because the situations in which we call `.push()` are: - Inside of `_flush()` itself, where it’s okay to skip `_flush()` calls because the input character queue is being processed already - Inside `_forwardAndBlockOnNewline()` for urgent events (Ctrl+C/Ctrl+D), where it’s also okay to skip `_flush()` calls because we call that immediately afterwards - Inside `_forwardWithoutBlocking()`, where the assumption would be that there is no character queue to be flushed in the first place Unfortunately, this adds a bit of extra complexity to the already somewhat complicated `LineByLineInput` implementation.
1 parent 8b5a2b1 commit 2c7d950

File tree

2 files changed

+34
-1
lines changed

2 files changed

+34
-1
lines changed

packages/cli-repl/src/line-by-line-input.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,22 @@ describe('LineByLineInput', () => {
6363
expect(forwardedChunks).to.deep.equal(['a', 'b', '\n']);
6464
});
6565
});
66+
67+
context('when a data listener calls nextLine() itself after Ctrl+C', () => {
68+
it('does not emit data while already emitting data', () => {
69+
let dataCalls = 0;
70+
let insideDataCalls = 0;
71+
lineByLineInput.on('data', () => {
72+
expect(insideDataCalls).to.equal(0);
73+
insideDataCalls++;
74+
if (dataCalls++ === 0) {
75+
lineByLineInput.nextLine();
76+
}
77+
insideDataCalls--;
78+
});
79+
stdinMock.emit('data', Buffer.from('foo\n\u0003'));
80+
expect(dataCalls).to.equal(5);
81+
expect(forwardedChunks).to.deep.equal(['\u0003', 'f', 'o', 'o', '\n']);
82+
});
83+
});
6684
});

packages/cli-repl/src/line-by-line-input.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class LineByLineInput extends Readable {
2727
private _blockOnNewLineEnabled: boolean;
2828
private _charQueue: (string | null)[];
2929
private _decoder: StringDecoder;
30+
private _insidePushCalls: number;
3031

3132
constructor(readable: NodeJS.ReadStream) {
3233
super();
@@ -35,6 +36,7 @@ export class LineByLineInput extends Readable {
3536
this._blockOnNewLineEnabled = true;
3637
this._charQueue = [];
3738
this._decoder = new StringDecoder('utf-8');
39+
this._insidePushCalls = 0;
3840

3941
const isReadableEvent = (name: string) =>
4042
name === 'readable' || name === 'data' || name === 'end' || name === 'keypress';
@@ -144,7 +146,11 @@ export class LineByLineInput extends Readable {
144146
// have in the buffer if in the meanwhile something
145147
// downstream explicitly called pause(), as that may cause
146148
// unexpected behaviors.
147-
!this._originalInput.isPaused()
149+
!this._originalInput.isPaused() &&
150+
151+
// If we are already inside a push() call, then we do not need to flush
152+
// the queue again.
153+
this._insidePushCalls === 0
148154
) {
149155
const char = this._charQueue.shift() as string | null;
150156

@@ -167,4 +173,13 @@ export class LineByLineInput extends Readable {
167173
private _isCtrlC(char: string | null): boolean {
168174
return char === CTRL_C;
169175
}
176+
177+
push(chunk: Buffer | string | null): boolean {
178+
this._insidePushCalls++;
179+
try {
180+
return super.push(chunk);
181+
} finally {
182+
this._insidePushCalls--;
183+
}
184+
}
170185
}

0 commit comments

Comments
 (0)