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

Add an event system #8021

Merged
merged 2 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# we use tokio_unstable to enable runtime::Handle::id so we can separate
# globals from multiple parallel tests. If that function ever does get removed
# its possible to replace (with some additional overhead and effort)
# Annoyingly build.rustflags doesn't work here because it gets overwritten
# if people have their own global target.<..> config (for example to enable mold)
# specifying flags this way is more robust as they get merged
# This still gets overwritten by RUST_FLAGS though, luckily it shouldn't be necessary
# to set those most of the time. If downstream does overwrite this its not a huge
# deal since it will only break tests anyway
[target."cfg(all())"]
rustflags = ["--cfg", "tokio_unstable", "-C", "target-feature=-crt-static"]
Copy link
Member

Choose a reason for hiding this comment

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

Why is crt-static also enabled here now?

Copy link
Member

Choose a reason for hiding this comment

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

And why are we applying this flag in all cases now, could we only apply it for test builds?

Copy link
Member Author

@pascalkuthe pascalkuthe Dec 12, 2023

Choose a reason for hiding this comment

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

crt static is being disabled (not enabled that would be +crt-static instead of -crt-static). The reason its here is that if we set RUST_FLAGS env variable then rusflags from .cargo/config.toml gets overwritten. On some distros (like musl) it's necessary to disable static linking for helx to work (helix can never work with a statically linked libc).

While its true that tokio_unstable is only needed during (integration test) the flag doesn't matter much (these tokio functions always exists as they are used internally, the flag just makes them pub). I tried coming up with a way to only enable during integration testing but that doesn't seem to work since you can't do cfg based on feature in cargo/config.toml only targets. cfg(test) doesn't work since cfg test only applied to the crate being tested - in this case helix-term - and not dependencies - in this case helix-event -



[alias]
xtask = "run --package xtask --"
integration-test = "test --features integration --profile integration --workspace --test integration"

7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion book/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ Its settings will be merged with the configuration directory `config.toml` and t
| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
| `auto-format` | Enable automatic formatting on save | `true` |
| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `250` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` |
| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` |
| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
Expand Down
17 changes: 15 additions & 2 deletions helix-event/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,18 @@ homepage.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot"] }
parking_lot = { version = "0.12", features = ["send_guard"] }
ahash = "0.8.3"
hashbrown = "0.14.0"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] }
# the event registry is essentially read only but must be an rwlock so we can
# setup new events on initialization, hardware-lock-elision hugely benefits this case
# as it essentially makes the lock entirely free as long as there is no writes
parking_lot = { version = "0.12", features = ["hardware-lock-elision"] }
once_cell = "1.18"

anyhow = "1"
log = "0.4"
futures-executor = "0.3.28"

[features]
integration_test = []
19 changes: 19 additions & 0 deletions helix-event/src/cancel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use std::future::Future;

pub use oneshot::channel as cancelation;
use tokio::sync::oneshot;

pub type CancelTx = oneshot::Sender<()>;
pub type CancelRx = oneshot::Receiver<()>;

pub async fn cancelable_future<T>(future: impl Future<Output = T>, cancel: CancelRx) -> Option<T> {
tokio::select! {
biased;
_ = cancel => {
None
}
res = future => {
Some(res)
}
}
}
67 changes: 67 additions & 0 deletions helix-event/src/debounce.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! Utilities for declaring an async (usually debounced) hook

use std::time::Duration;

use futures_executor::block_on;
use tokio::sync::mpsc::{self, error::TrySendError, Sender};
use tokio::time::Instant;

/// Async hooks provide a convenient framework for implementing (debounced)
/// async event handlers. Most synchronous event hooks will likely need to
/// debounce their events, coordinate multiple different hooks and potentially
/// track some state. `AsyncHooks` facilitate these use cases by running as
/// a background tokio task that waits for events (usually an enum) to be
/// sent through a channel.
pub trait AsyncHook: Sync + Send + 'static + Sized {
type Event: Sync + Send + 'static;
/// Called immediately whenever an event is received, this function can
/// consume the event immediately or debounce it. In case of debouncing,
/// it can either define a new debounce timeout or continue the current one
fn handle_event(&mut self, event: Self::Event, timeout: Option<Instant>) -> Option<Instant>;

/// Called whenever the debounce timeline is reached
fn finish_debounce(&mut self);

fn spawn(self) -> mpsc::Sender<Self::Event> {
pascalkuthe marked this conversation as resolved.
Show resolved Hide resolved
// the capacity doesn't matter too much here, unless the cpu is totally overwhelmed
// the cap will never be reached since we always immediately drain the channel
// so it should only be reached in case of total CPU overload.
// However, a bounded channel is much more efficient so it's nice to use here
let (tx, rx) = mpsc::channel(128);
tokio::spawn(run(self, rx));
tx
}
}

async fn run<Hook: AsyncHook>(mut hook: Hook, mut rx: mpsc::Receiver<Hook::Event>) {
let mut deadline = None;
loop {
let event = match deadline {
Some(deadline_) => {
let res = tokio::time::timeout_at(deadline_, rx.recv()).await;
match res {
Ok(event) => event,
Err(_) => {
hook.finish_debounce();
deadline = None;
continue;
}
}
}
None => rx.recv().await,
};
let Some(event) = event else {
break;
};
deadline = hook.handle_event(event, deadline);
}
}

pub fn send_blocking<T>(tx: &Sender<T>, data: T) {
// block_on has some overhead and in practice the channel should basically
// never be full anyway so first try sending without blocking
if let Err(TrySendError::Full(data)) = tx.try_send(data) {
// set a timeout so that we just drop a message instead of freezing the editor in the worst case
let _ = block_on(tx.send_timeout(data, Duration::from_millis(10)));
}
}
91 changes: 91 additions & 0 deletions helix-event/src/hook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! rust dynamic dispatch is extremely limited so we have to build our
//! own vtable implementation. Otherwise implementing the event system would not be possible.
//! A nice bonus of this approach is that we can optimize the vtable a bit more. Normally
//! a dyn Trait fat pointer contains two pointers: A pointer to the data itself and a
//! pointer to a global (static) vtable entry which itself contains multiple other pointers
//! (the various functions of the trait, drop, size and align). That makes dynamic
//! dispatch pretty slow (double pointer indirections). However, we only have a single function
//! in the hook trait and don't need a drop implementation (event system is global anyway
//! and never dropped) so we can just store the entire vtable inline.

use anyhow::Result;
use std::ptr::{self, NonNull};

use crate::Event;

/// Opaque handle type that represents an erased type parameter.
///
/// If extern types were stable, this could be implemented as `extern { pub type Opaque; }` but
/// until then we can use this.
///
/// Care should be taken that we don't use a concrete instance of this. It should only be used
/// through a reference, so we can maintain something else's lifetime.
struct Opaque(());
pascalkuthe marked this conversation as resolved.
Show resolved Hide resolved

pub(crate) struct ErasedHook {
data: NonNull<Opaque>,
call: unsafe fn(NonNull<Opaque>, NonNull<Opaque>, NonNull<Opaque>),
}

impl ErasedHook {
pub(crate) fn new_dynamic<H: Fn() -> Result<()> + 'static + Send + Sync>(
hook: H,
) -> ErasedHook {
unsafe fn call<F: Fn() -> Result<()> + 'static + Send + Sync>(
hook: NonNull<Opaque>,
_event: NonNull<Opaque>,
result: NonNull<Opaque>,
) {
let hook: NonNull<F> = hook.cast();
let result: NonNull<Result<()>> = result.cast();
let hook: &F = hook.as_ref();
let res = hook();
ptr::write(result.as_ptr(), res)
}

unsafe {
ErasedHook {
data: NonNull::new_unchecked(Box::into_raw(Box::new(hook)) as *mut Opaque),
call: call::<H>,
}
}
}

pub(crate) fn new<E: Event, F: Fn(&mut E) -> Result<()>>(hook: F) -> ErasedHook {
unsafe fn call<E: Event, F: Fn(&mut E) -> Result<()>>(
hook: NonNull<Opaque>,
event: NonNull<Opaque>,
result: NonNull<Opaque>,
) {
let hook: NonNull<F> = hook.cast();
let mut event: NonNull<E> = event.cast();
let result: NonNull<Result<()>> = result.cast();
let hook: &F = hook.as_ref();
let res = hook(event.as_mut());
ptr::write(result.as_ptr(), res)
}

unsafe {
ErasedHook {
data: NonNull::new_unchecked(Box::into_raw(Box::new(hook)) as *mut Opaque),
call: call::<E, F>,
}
}
}

pub(crate) unsafe fn call<E: Event>(&self, event: &mut E) -> Result<()> {
let mut res = Ok(());

unsafe {
(self.call)(
self.data,
NonNull::from(event).cast(),
NonNull::from(&mut res).cast(),
);
}
res
}
}

unsafe impl Sync for ErasedHook {}
unsafe impl Send for ErasedHook {}
Loading
Loading