node icon indicating copy to clipboard operation
node copied to clipboard

repl: limit line processing to one at a time

Open ejose19 opened this issue 3 years ago • 37 comments

Fixes: #39387

ejose19 avatar Jul 14 '21 23:07 ejose19

@guybedford Feel free to take a look at this

ejose19 avatar Jul 15 '21 00:07 ejose19

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 avatar Jul 15 '21 16:07 ejose19

@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 avatar Jul 15 '21 19:07 guybedford

@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 avatar Jul 15 '21 21:07 ejose19

@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 avatar Jul 15 '21 23:07 guybedford

@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 avatar Jul 16 '21 00:07 ejose19

@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 avatar Jul 16 '21 00:07 guybedford

@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);
  })();
}

ejose19 avatar Jul 16 '21 00:07 ejose19

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.

guybedford avatar Jul 16 '21 02:07 guybedford

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 avatar Jul 16 '21 14:07 ejose19

@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 avatar Jul 16 '21 14:07 guybedford

@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.

ejose19 avatar Jul 16 '21 14:07 ejose19

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.

guybedford avatar Jul 16 '21 14:07 guybedford

One other question - what will be the outcome for eval implementations that do not call the callback? Will it just stall?

guybedford avatar Jul 16 '21 14:07 guybedford

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 avatar Jul 16 '21 14:07 ejose19

@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 avatar Jul 16 '21 14:07 guybedford

@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?

ejose19 avatar Jul 16 '21 15:07 ejose19

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.

ejose19 avatar Jul 16 '21 15:07 ejose19

Ok, thanks for hearing me out on these questions, agreed the major is best then with the callback being enforced.

guybedford avatar Jul 16 '21 16:07 guybedford

CI: https://ci.nodejs.org/job/node-test-pull-request/39094/

nodejs-github-bot avatar Jul 16 '21 16:07 nodejs-github-bot

/cc @addaleax I think this might impact you

targos avatar Jul 17 '21 09:07 targos

@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.

addaleax avatar Jul 17 '21 09:07 addaleax

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/

targos avatar Jul 19 '21 07:07 targos

CI: https://ci.nodejs.org/job/node-test-pull-request/39124/

nodejs-github-bot avatar Jul 19 '21 14:07 nodejs-github-bot

CI: https://ci.nodejs.org/job/node-test-pull-request/39451/

nodejs-github-bot avatar Aug 04 '21 06:08 nodejs-github-bot

@ejose19 Can you please rebase to fix the git conflict?

aduh95 avatar Sep 15 '21 16:09 aduh95

@aduh95 should be good now

ejose19 avatar Sep 20 '21 14:09 ejose19

CI: https://ci.nodejs.org/job/node-test-pull-request/40003/

nodejs-github-bot avatar Sep 20 '21 15:09 nodejs-github-bot

@ejose19 Can you please rebase and drop the merge commit? Merge commits break our tooling unfortunately.

aduh95 avatar Sep 20 '21 23:09 aduh95

@aduh95 done

ejose19 avatar Sep 28 '21 19:09 ejose19