node
node copied to clipboard
repl: limit line processing to one at a time
Fixes: #39387
@guybedford Feel free to take a look at this
I'd like to add the output of debug
to my test assertions, so I confirm the queue flow is happening as expected, but didn't see any other repl test that is asserting debug
output (and using NODE_DEBUG=repl
before calling the tests makes the output end up in my terminal instead of repl output stream), any ideas how I can do it?
@ejose19 I'm not aware of any debug stream testing, but there might be some examples in the existing tests directory to be found. Alternatively setting up something custom could be worthwhile unless someone else can clarify further here.
@guybedford I had to replace the original debuglog
function before importing repl
in order to access debug
data. That should be enough for this test.
@ejose19 if we're doing a major change here anyway, should we also support eval
returning a Promise? Or is the callback definitely necessary to get the correct timings?
@guybedford That would require a major overhaul in the tests, since most of them expect the output right after writing the input, which is the case for sync code. But if eval
is async, code execution (and output) would be done in the next iteration.
So even if using async eval
would be ideal, it's outside the scope of this PR due the large amount of changes.
@ejose19 what I mean is branching the API on sync / async handling with:
eval: () => Promise | undefined
where the undefined case is treated as sync, and the promise case is treated as the async situation, for example rather than enforcing the callback. That's not the exact approach but along those lines I mean.
@guybedford you mean something like this?
const evalResult = self.eval(evalCmd, self.context, getREPLResourceName(), finish);
if (evalResult !== undefined) {
if (!(evalResult instanceof Promise)) {
self._domain.emit('error', 'eval must return "undefined" or a "Promise"');
return;
}
(async () => {
const { error, result } = await evalResult;
finish(error, result);
})();
}
Yes that seems like the sort of approach if you agree it could make sense. Would this also allow this PR to be done in a backwards compatible way perhaps? Perhaps we should consider being more lenient on the return value to be safe in that case? Either way wrt the validation could work for me though.
I don't think it can be made backward compatible, we can't assume undefined
= "sync code", as it's not even the case for node own defaultEval
(notice how the promise is not returned):
https://github.com/nodejs/node/blob/44e382290ad624b09ec4466a48e77f87310de320/lib/repl.js#L606-L623
If other projects based their eval
implementation following node defaultEval
, then undefined
can also mean promise handling.
Ideally, self.on('line')
should be refactored into for await (const cmd of self)
, so line processing is guaranteed to be one at a time without needing a queue, and eval
should be made a Promise
resolving to { err?: any, result?: any }
, to avoid the need of a callback completely and making the implementation more direct. But that comes with all the tests refactor and the "breaking" change of writing to stdin will no longer yield a response in the current tick.
@ejose19 I guess I'm still not clear on how the callback can be optional currently. But if the current eval doesn't return a value, then allowing the current eval to return a promise would be a backwards-compatible extension. Then allowing that promise to be a completion based callback that fits the requirements you needed for the async callback could also be a way to possible shoehorn this feature in a backwards compatible way (separate to the explicit callback mechanism). Again I'm not super clear on the details of this code, so fill me in where I'm wrong :)
@guybedford you mean something like this?
const evalResult = self.eval(evalCmd, self.context, getREPLResourceName(), finish); if (evalResult !== undefined) { if (!(evalResult instanceof Promise)) { self._domain.emit('error', 'eval must return "undefined" or a "Promise"'); return; } (async () => { const { error, result } = await evalResult; finish(error, result); })(); }
@guybedford If something like this is added to the PR, then eval
must either:
- Return a
Promise
that returns{ err?: any, result?: any }
- Execute
callback
For any code that is not returning a Promise
and neither was executing the callback
, it WILL be a breaking change since new lines won't be allowed to be processed (you noticed that it was necessary to execute the callback
on tests, and probably some consumers have a similar implementation).
So what I mean is, while we can improve the "handling" of eval
supporting both undefined
(but executed/will execute callback) and Promise
as return values for eval
, I don't see how it can be backward compatible for current code that does neither.
So because the invariant of per-line processing requires all eval implementations to conform, there's no middle ground here on creating a backwards compatible subset, got it.
In that case moving ahead with this as-is seems fine to me.
One other question - what will be the outcome for eval implementations that do not call the callback? Will it just stall?
One other question - what will be the outcome for eval implementations that do not call the callback? Will it just stall?
Correct, for most commands that will be the outcome, unless other event resets the processing
property to false
(like domain errors or SIGINT)
@ejose19 another option could be to check the function length of the eval
function to see if its length includes the callback argument position. If not included it could be assumed to be "sync".
@guybedford Ok, that's an interesting approach, but it's enough for this to be considered non breaking? What about consumers that defined the argument but never called it?
I tried with that, and it seems this line: https://github.com/nodejs/node/blob/44e382290ad624b09ec4466a48e77f87310de320/lib/repl.js#L632
makes the function to have a different length than the original (0 vs 4 for defaultEval
), so even if we compare against eval_
directly, I don't think it's safe to "assume" the consumer won't execute the callback based on that alone, in case they're doing something similar to node before passing the function to repl creation.
Ok, thanks for hearing me out on these questions, agreed the major is best then with the callback being enforced.
CI: https://ci.nodejs.org/job/node-test-pull-request/39094/
/cc @addaleax I think this might impact you
@targos It definitely does :) I’ve been meaning to take a look at it and just see if this breaks something for us. But at a glance, from our/my perspective this looks more like a bug fix that allows us to reduce the monkey-patching of the REPL that we do, rather than a breaking change.
I don't know if we have coverage for the REPL in CITGM, but here's a run: https://ci.nodejs.org/view/Node.js-citgm/job/citgm-smoker/2727/
CI: https://ci.nodejs.org/job/node-test-pull-request/39124/
CI: https://ci.nodejs.org/job/node-test-pull-request/39451/
@ejose19 Can you please rebase to fix the git conflict?
@aduh95 should be good now
CI: https://ci.nodejs.org/job/node-test-pull-request/40003/
@ejose19 Can you please rebase and drop the merge commit? Merge commits break our tooling unfortunately.
@aduh95 done