Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Headless mode (#1354) rebase #2054

Closed
wants to merge 13 commits into from
Closed

Headless mode (#1354) rebase #2054

wants to merge 13 commits into from

Conversation

aartaka
Copy link
Contributor

@aartaka aartaka commented Jan 25, 2022

This is a fresh rebase of #1354. Not much changed -- a good sign of prompter having a good and approachable API!

Things to DIscuss

  • Should prompt-buffer-channel be a hook, actually?

Things to do

  • Test it with some non-trivial scripting.
  • nyxt-ready-hook to start interactive commands from.
  • Maybe utility macros to ease scripting? (see Selenium)

Closes #1168.

@aartaka
Copy link
Contributor Author

aartaka commented Jan 25, 2022

@Ambrevar, how does one set prompt input? update-prompt-input doesn't seem to work well somewhy...

See tests/renderer-online/set-url.lisp.

source/browser.lisp Outdated Show resolved Hide resolved
@Ambrevar
Copy link
Member

Indeed, you must call set-prompt-buffer-input.
update-prompt-buffer is to update the display. The fact that it takes an input is mostly an implementation detail.

We must fix this bad API. I suggest documenting it (e.g. mentioning set-prompt-buffer-input in the docstring).
Should we unexport update-prompt-input and export set-prompt-buffer-input instead?

@Ambrevar
Copy link
Member

Thanks for this update!

It's a great start, but as you can see, it's a lot of boilerplate code just to drive a simple set-url!!! :p

In my opinion, we should aim for something much simpler.
We previously discussed whether we should use hooks or channels.
Actually, I see now benefits of channels:

  • no need to declare functions / lambdas, you can listen / write straight from the top level.
  • No need to clean up the hook when you are done, since reading and writing are a one-time operation.
    The downsides:
  • non-theaded code is blocking; while adding to a hook does not block.

So what does it mean to aim for "short code"? Let's try to reach this pseudo-code:

(let ((url-channel (nyxt::make-channel 1))
        (url "http://example.org/"))
    (setf nyxt::*headless-p* t)
    (nhooks:add-hook
     nyxt:*after-SOME-hook*
     (make-instance
      'nhooks:handler
      :fn (lambda ()
            (nyxt::set-prompt-buffer-input (nyxt:current-prompt-buffer) url)
            (nyxt/prompt-buffer-mode:return-selection)
            (nhooks:add-hook
             (buffer-loaded-hook (current-buffer))
             (make-instance
              'nhooks:handler
              :fn (lambda (buffer)
                    (calispel:! url-channel (nyxt:render-url (nyxt:url buffer)))
                    (nhooks:remove-hook (buffer-loaded-hook buffer) 'example-org-loaded))
              :name 'example-org-loaded)))
      :name 'browser-started))
    (nyxt:start :no-init t :no-auto-config t
                :socket "/tmp/nyxt-test.socket"
                :data-profile "test")
    (prove:is (calispel:? url-channel 10) url))

To reach there, it would be nice if the first buffer load could be controlled somehow.

@Ambrevar
Copy link
Member

And here is the same pseudo code, using the channel paradigm:

(let ((url-channel (nyxt::make-channel 1))
        (url "http://example.org/"))
    (setf nyxt::*headless-p* t)
    (when (calispel:? nyxt:*after-SOME-startup-channel*)
      (nyxt::set-prompt-buffer-input (nyxt:current-prompt-buffer) url)
      (nyxt/prompt-buffer-mode:return-selection)
      (when (calispel:? nyxt:*after-buffer-loaded-channel*)
        (prove:is (nyxt:url buffer) url)))
    (nyxt:start :no-init t :no-auto-config t
                :socket "/tmp/nyxt-test.socket"
                :data-profile "test"))

As you can see, it's much shorter and easier to write.
Now channels values are read by only one reader at a time. What it we want to unblock all the sites that are waiting on a channel? We need some other mechanism, maybe a form of "broadcast channel"? Any idea? Any CL library for that?

@Ambrevar
Copy link
Member

Also now that internal buffers have URLs, we can set the URL to something internal, thus no network is needed and we can remove the timeouts :)

@aartaka
Copy link
Contributor Author

aartaka commented Jan 26, 2022

Indeed, you must call set-prompt-buffer-input. update-prompt-buffer is to update the display. The fact that it takes an input is mostly an implementation detail.

Thanks for the hint!

We must fix this bad API. I suggest documenting it (e.g. mentioning set-prompt-buffer-input in the docstring). Should we unexport update-prompt-input and export set-prompt-buffer-input instead?

Documenting could indeed help. I'd export both, as update-prompt-buffer-input is used in several places in Nyxt code, while set-prompt-buffer-input can come in handy for automation.

@aartaka
Copy link
Contributor Author

aartaka commented Jan 26, 2022

Regarding hooks vs. channels:

I don't like channels in the context of event handling, as those block the thread they are on, are extremely time-sensitive, and are a concept that is hard to understand at first.

Hooks are familliar, somewhat-asynchronous, and more flexible in general. The issue that you identified is merely a notation one -- hook addition is overly verbose and nested. But it's all Lisp -- we can write macros to shorten the code. I suggest at least two macros to handle hooks: on and once-on. on simply adds a handler to the hook, while once-on also deletes it after it fires (which is a frequent use-case). Examples:

(once-on (buffer-loaded-hook buffer) ; Hook.
    (buffer) ; Lambda list for the callback.
  (calispel:! buffer-loaded-channel t)) ; hooks:remove-hook is hidden.

(on (after-download-hook)
    (download)
  (uiop:launch-program (list "xdg-open" (nyxt::destination-path download))))

Obviously, JavaScript with its await syntax is an influence here.

Alternatively, we can introduce something similar to Haskell's do-notation, but based on hooks:

(hook-do
  (buffer) <- (buffer-loaded-hook (current-buffer)) ; BUFFER is only set when `buffer-loaded-hook' fires
  (echo "Buffer ~a loaded ~a" (id buffer) (render-url (url buffer))) ; Inside `buffer-loaded-hook'.
  (calispel:! buffer-loaded-channel t) ; Inside `buffer-loaded-hook'.
  (request-data) <- (request-resource-hook buffer) ; REQUEST-DATA is only set when the hook fires.
  (not (search "google" (render-url (url request-data))))) ; Inside `request-resource-hook` and `buffer-loaded-hook'.

@Ambrevar
Copy link
Member

Ambrevar commented Jan 26, 2022 via email

@aartaka
Copy link
Contributor Author

aartaka commented Jan 26, 2022

Good points! I'd like to clarify something about what I meant with my "broadcast" channels: what we are really dealing with here is called "signal" in the GTK sense (close to Unix signals, not to be confused with unrelated CL signals).

Now I understand it. But still, why introduce channels as a user-facing thing, if we have hooks?

Blocking is indeed a benefit: more often than not you don't want to execute the following code until you got some result. Another approach would be to use futures and promises. Are you familiar with these? What do you think?

The code I suggested is heavily inspired by JavaScript Promises handling, so yes, I am familliar with those. But still, our pseudo-promise hooks would work just fine, given enough syntactic sugar, so why introduce yet another abstraction?

EDIT: ouR, pseudO

@Ambrevar
Copy link
Member

Can you explain what you mean with buffer-loaded-channel in your previous examples?
Is it meant for blocking?
If not, we would need to introduce a way to make blocking calls, for instance (once-on (hook :block-p t) ...).

@aartaka
Copy link
Contributor Author

aartaka commented Jan 26, 2022

Can you explain what you mean with buffer-loaded-channel in your previous examples?

Oh, that was confusing, I suppose. It was basically a copy-paste from the test/renderer-online/set-url.lisp test, so buffer-loaded-channel doesn't matter that much.

Is it meant for blocking?

No :)

If not, we would need to introduce a way to make blocking calls, for instance (once-on (hook :block-p t) ...).

Hmmmmmm. I mean, why would we need to? All the code that needs to depend on the hook activating goes inside once-on and that should be enough. A typical callback hell nesting.

@Ambrevar
Copy link
Member

Ambrevar commented Jan 26, 2022 via email

@aartaka
Copy link
Contributor Author

aartaka commented Jan 26, 2022

Fair enough :) Can you give these macros a try then?

Sure!

Those are also used in tests/renderer-online/set-url.lisp now.
@aartaka
Copy link
Contributor Author

aartaka commented Jan 27, 2022

on and once-on added in 4628258!

Do they maybe belong to hooks rather than to the core of Nyxt?

@aartaka
Copy link
Contributor Author

aartaka commented Jan 27, 2022

And can we make prompt-buffer-channel to be a hook? Will we lose some of its features when doing so?

@Ambrevar
Copy link
Member

Ambrevar commented Jan 27, 2022 via email

@Ambrevar
Copy link
Member

Ambrevar commented Jan 27, 2022 via email

@Ambrevar
Copy link
Member

Forgot to answer your other question: yes, I think we can add them to
nhooks. Let's discuss this with @BlueFlo0d !

@kchanqvq
Copy link
Contributor

kchanqvq commented Jan 27, 2022

Alternatively, we can introduce something similar to Haskell's do-notation, but based on hooks:

Haskell sucks (irrelevant to topic sorry). What about good'ol direct style (w.r.t. CPS).

(cps
 (let ((buffer (? (buffer-loaded-hook (current-buffer)))))
   (echo "Buffer ~a loaded ~a" (id buffer) (render-url (url buffer)))
   (calispel:! buffer-loaded-channel t)
   (not (search "google" (render-url (url (? (request-resource-hook buffer))))))))

With regard to this use case of hook, it does seem to fit nicely, but I have to think more, there's something that I feel off.

I don't like channels in the context of event handling, as those block the thread they are on, are extremely time-sensitive, and are a concept that is hard to understand at first.

Maybe that's exactly what I'm worried about, but for hooks. I think the channel behavior is more understandable as it resembles the command loop paradigm in Emacs (writing channels explicitly is confusing, but if you hide them in functions, those functions just look like normal functions that take longer time to run). The simple control flow is already broken to a certain degree in Nyxt, for goodness or badness. On the other hand, hooks+CPS transform (or call it by some other name) causes following code to run in a different thread, and the order of execution can be unpredictable (from mental model aspect) if multiple handlers are registered.

I'm not saying the Emacs command loop is the way to go, people are cursing it forever for its single-threadness and non-responsiveness, but there're other aspects of it that I like a lot. Just want to add this factor to the discussion.

@aartaka
Copy link
Contributor Author

aartaka commented Jan 27, 2022

The prompt-buffer-ready-hook is there instead of prompt-buffer-channel, macros are fixed. What's left is discussing things :)

@Ambrevar
Copy link
Member

Ambrevar commented Jan 27, 2022 via email

@kchanqvq
Copy link
Contributor

Another approach would be to use futures and promises. Are you familiar with these? What do you think?

We can just use cl-cont.

@Ambrevar
Copy link
Member

@BlueFlo0d Can you provide an example of how we could leverage continuations for headless / prompt-buffer scripting?

@Ambrevar
Copy link
Member

Ambrevar commented Jan 27, 2022

I'll have a look at the (sleep 1) issue later.
Left to do:

  • Export set-prompt-buffer-input.
  • Document it with update-prompt-buffer.
  • Add offline test with internal URL.

@kchanqvq
Copy link
Contributor

@BlueFlo0d Can you provide an example of how we could leverage continuations for headless / prompt-buffer scripting?

See my direct style example above.


(cps
 (let ((buffer (? (buffer-loaded-hook (current-buffer)))))
   (echo "Buffer ~a loaded ~a" (id buffer) (render-url (url buffer)))
   (calispel:! buffer-loaded-channel t)
   (not (search "google" (render-url (url (? (request-resource-hook buffer))))))))

You can call ? just like a normal function which “looks like” waiting for a hook to run, and it can be implemented by capturing the continuation of the ? and register as a handler to said hook.

@aartaka
Copy link
Contributor Author

aartaka commented Feb 10, 2022

As an addition, should we have --headless CLI argument to Nyxt to start it headless (with *headless-p* set to true) right away?

@Ambrevar
Copy link
Member

Ambrevar commented Feb 10, 2022 via email

@Ambrevar
Copy link
Member

Should I address the remaining issues and merge this?

@Ambrevar
Copy link
Member

@BlueFlo0d Sorry, I don't understand the code completely. What do ? and cps do?

@aartaka
Copy link
Contributor Author

aartaka commented Feb 17, 2022

Yes, if you don't mind :)

@@ -23,7 +27,7 @@ It can be initialized with
It's possible to run multiple interfaces of Nyxt at the same time. You can
let-bind *browser* to temporarily switch interface.")

(declaim (type hooks:hook-void *after-init-hook*))
(declaim (type hooks:hook-void *after-init-hook* *after-startup-hook*))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move this to the declaration site.

ARGS can be
- A symbol if there's only one argument to the callback.
- A list of arguments.
- An empty list, if the hook is void."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean "if the hook handlers take no argument"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly :)

@@ -34,6 +34,7 @@ It's a function of the window argument that returns the title as a string.")
:export nil
:type boolean
:documentation "Whether the window is displayed in fullscreen.")
;; TODO: each frame should have a status buffer, not each window
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to the pull request?

What do you mean? We don't have frames (beside panel buffers), do we?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment was there before the patch. Seems like it was deleted on master 0_o


(class-star:define-class nyxt-user::test-data-profile (nyxt:data-profile)
((nyxt:name :initform "test"))
(:documentation "Test profile."))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be updated since master no longer has data-profiles.

@Ambrevar
Copy link
Member

Replacing sleep 1 with (prompter:all-ready-p prompt-buffer) should work, no?

@aartaka
Copy link
Contributor Author

aartaka commented Feb 17, 2022

Yes, could work!

@kchanqvq
Copy link
Contributor

@BlueFlo0d Sorry, I don't understand the code completely. What do ? and cps do?

(cps &body body) is a macro that do CPS transform to its enclosing forms. This supports using ? inside a cps form.

Behaviorally, (? hook) block and wait for hook to be called, and return some value only after that.

There're several possibility about how to specify what value to return, maybe the most straightforward one being using the syntax (? hook return-form), and evaluate return-form only after hook is called. Or maybe just plainly (? hook) and always return nil, and let user get needed values themselves. Or it could depend on the hook combination, and return whatever value passed to the hook handler. I think the last one is the most natural.

The magic CPS transform brings is that, (? hook) doesn't actually need to block any thread -- it simply reify its current continuation and register it as a hook handler. In the example above, the continuation of (? (buffer-loaded-hook (current-buffer))) is roughly speaking something like

 (lambda (buffer)
   (echo "Buffer ~a loaded ~a" (id buffer) (render-url (url buffer)))
   (calispel:! buffer-loaded-channel t)
   (not (search "google" (render-url (url (? (request-resource-hook buffer)))))))

and this will be registered as a handler of (buffer-loaded-hook (current-buffer)). Code outside the cps form can execute without blocking, and the above continuation (or, the "rest" of code inside cps form) will be executed in the thread running (buffer-loaded-hook (current-buffer)) later.

@Ambrevar
Copy link
Member

Ambrevar commented Feb 18, 2022 via email

@kchanqvq
Copy link
Contributor

kchanqvq commented Feb 18, 2022

Understood. If I understood you right, you are talking about a hypothetical cps macro, not cl-cont.

cps is actually basically just cl-cont:with-continuation, IIRC. I'm pretty sure we can implement the above described cps and ? functionality within 20 lines using cl-cont and nhooks.

If you implement the continuation passing by adding the continuation as a handler to the hook, is it different from the on macro that Artyom just implemented?

They're apart from each other by one CPS transform. Basically, using on, you have to write explicitly in continuation-passing style, while using cps/cl-cont, you write in direct style and the macros convert your code to CPS under the hood. Imagine if you have some computation that need to wait for several "events" (formally hook triggering) along the way, in CPS style you have to write (progn A (on (...) ... B (on (...) ... C (on (...) ... D)))), while in direct style you just need to write (progn A (? ...) B (? ...) C (? ...) D). The difference become larger when dealing with more complicated procedure. Another advantage of cps/cl-cont will emerge when you consider factoring some asynchronous code into functions. In CPS style, you may write (progn A (on (h1) ... (let ((result (f)) B))) to arrange (let ((result (f)) B)) to run after h1, however if in some case (f) itself need to wait for something, there's no way to delay B until f produce sensible result. There is a way without using cps/cl-cont -- you just need to in turn write f as a CPS function, something like (defun f (cont) (on (h2) ...(funcall cont ...))) and then write (progn A (on (h1) (f (lambda (result) B)))), however it's much less a hassle to write everything normally and let cl-cont take care of it.

@Ambrevar
Copy link
Member

Ambrevar commented Feb 21, 2022 via email

@kchanqvq
Copy link
Contributor

So it's mostly a syntactic benefit you are proposing.

I don't think this is syntactic. I think the "factoring some asynchronous code into functions" section shows it pretty clearly. Blocking computation within a dynamic extent from anywhere inside is a straightforward insertion of ? in direct style (or, "my CPS style"), while is impossible without propagating changes through all the code within such dynamic extent in CPS form (or, "on style").

The well-know definition of "syntactic sugar", an synonym of "macro expressibility", defines equi-expressibility as mutually translatable via local syntactic transform (See the classic paper
Felleisen, Matthias. 1990 "On the expressive power of programming languages."). CPS transform is well known to be a non-local transform and continuation capturing is real expressive power. You can't sweep everything under the rug of "being syntactic", otherwise you will be equating LISP and assembly and Turing machine. CPS transform is known to be non-purely-syntactic.

I suggest we merge on-hook for now and if you think your CPS style is superior, please send a working patch to exhibit the benefits, it will help with the discussion I'm sure :)

Sure, I'll try to find sometime to do it.

@Ambrevar
Copy link
Member

Merged with efa3b7d.

1 similar comment
@Ambrevar
Copy link
Member

Merged with efa3b7d.

@Ambrevar Ambrevar closed this Feb 24, 2022
@Ambrevar
Copy link
Member

Ambrevar commented Mar 9, 2022

Note: I found this library for events / notifications:

https://github.com/fukamachi/event-emitter

Looks very similar to @aartaka's implementation!

@jmercouris jmercouris deleted the wip-headless-rebase branch June 16, 2022 02:34
@Ambrevar Ambrevar mentioned this pull request Dec 23, 2022
20 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Make Nyxt configurable as headless
3 participants