Skip to content
Open
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
11 changes: 11 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ clearscreen = "2.0.1"
command-group = { version = "2.1.0", features = ["tokio", "with-tokio"] }
crossterm = { version = "0.27.0", features = ["event-stream"] }
enum-iterator = "1.4.1"
fs-err = { version = "2.11.0", features = ["tokio"] }
humantime = "2.1.0"
ignore = "0.4.20"
indoc = "1.0.6"
Expand Down
15 changes: 15 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ pub struct Opts {
#[arg(long)]
pub no_interrupt_reloads: bool,

/// Extra paths to read logs from.
///
/// `ghciwatch` needs to parse `ghci`'s output to determine when reloads have finished and to
/// parse compiler errors, so libraries like Yesod that asynchronously print to stdout or
/// stderr are not supported.
///
/// Instead, you should have your program write its logs to a file and use its path as an
/// argument to this option. `ghciwatch` will read from the file and output logs inline with
/// the rest of its output.
///
/// See: https://github.com/ndmitchell/ghcid/issues/137
#[allow(rustdoc::bare_urls)]
#[arg(long)]
pub read_logs_from: Vec<Utf8PathBuf>,

/// Enable TUI mode (experimental).
#[arg(long, hide = true)]
pub tui: bool,
Expand Down
2 changes: 1 addition & 1 deletion src/ghci/error_log.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use camino::Utf8PathBuf;
use fs_err::tokio::File;
use miette::IntoDiagnostic;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::io::BufWriter;
use tracing::instrument;
Expand Down
3 changes: 2 additions & 1 deletion src/ghci/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
use aho_corasick::AhoCorasick;
use camino::Utf8Path;
use camino::Utf8PathBuf;
use fs_err::tokio as fs;
use miette::miette;
use miette::IntoDiagnostic;
use miette::WrapErr;
Expand Down Expand Up @@ -641,7 +642,7 @@ impl Ghci {
/// Read and parse eval commands from the given `path`.
#[instrument(level = "trace")]
async fn parse_eval_commands(path: &Utf8Path) -> miette::Result<Vec<EvalCommand>> {
let contents = tokio::fs::read_to_string(path)
let contents = fs::read_to_string(path)
.await
.into_diagnostic()
.wrap_err_with(|| format!("Failed to read {path}"))?;
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod ignore;
mod incremental_reader;
mod maybe_async_command;
mod normal_path;
mod read_logs_from;
mod shutdown;
mod string_case;
mod tracing;
Expand All @@ -43,6 +44,7 @@ pub use ghci::manager::run_ghci;
pub use ghci::Ghci;
pub use ghci::GhciOpts;
pub use ghci::GhciWriter;
pub use read_logs_from::ReadLogsFrom;
pub use shutdown::ShutdownError;
pub use shutdown::ShutdownHandle;
pub use shutdown::ShutdownManager;
Expand Down
13 changes: 13 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use ghciwatch::run_ghci;
use ghciwatch::run_tui;
use ghciwatch::run_watcher;
use ghciwatch::GhciOpts;
use ghciwatch::ReadLogsFrom;
use ghciwatch::ShutdownManager;
use ghciwatch::TracingOpts;
use ghciwatch::WatcherOpts;
Expand Down Expand Up @@ -45,6 +46,18 @@ async fn main() -> miette::Result<()> {
.await;
}

for path in opts.read_logs_from {
manager
.spawn("read-logs", |handle| {
ReadLogsFrom {
shutdown: handle,
path,
}
.run()
Comment on lines +52 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not a blocker, but it wasn't clear to me why this is split up as a struct and a method on that struct (i.e. ReadLogsFrom { shutdown: handle, path }.run()) and not combined into a single function (i.e. readLogsFrom(handle, path))

Copy link
Member Author

Choose a reason for hiding this comment

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

A couple reasons why I chose to implement it like this:

  • Struct+method is a lot easier to split up to deal with complex logic.
  • Methods don't have named parameters, so when there's a lot of state to pass in, using a struct is a lot clearer (and lower-overhead than defining builders).
  • Most of the async tasks in ghciwatch are structured like this.

})
.await;
}

manager
.spawn("run_ghci", |handle| {
run_ghci(handle, ghci_opts, ghci_receiver)
Expand Down
148 changes: 148 additions & 0 deletions src/read_logs_from.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use std::io::SeekFrom;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::time::Duration;

use backoff::backoff::Backoff;
use backoff::ExponentialBackoff;
use camino::Utf8Path;
use camino::Utf8PathBuf;
use fs_err::tokio as fs;
use fs_err::tokio::File;
use miette::miette;
use miette::IntoDiagnostic;
use tap::TryConv;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncSeekExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tracing::instrument;

use crate::ShutdownHandle;

/// Maximum number of bytes to print near the end of a log file, if it already has data when it's
/// opened.
const MAX_BYTES_PRINT_FROM_END: u64 = 0x200; // = 512

/// Me: Can we have `tail(1)`?
///
/// `ghciwatch`: We have `tail(1)` at home.
///
/// `tail(1)` at home:
pub struct ReadLogsFrom {
/// Shutdown handle.
pub shutdown: ShutdownHandle,
/// Path to read logs from.
pub path: Utf8PathBuf,
}

impl ReadLogsFrom {
/// Read logs from the given path and output them to stdout.
#[instrument(skip_all, name = "read-logs", level = "debug", fields(path = %self.path))]
pub async fn run(mut self) -> miette::Result<()> {
let mut backoff = ExponentialBackoff {
max_elapsed_time: None,
max_interval: Duration::from_secs(1),
..Default::default()
};
while let Some(duration) = backoff.next_backoff() {
match self.run_inner().await {
Ok(()) => {
// Graceful exit.
break;
}
Err(err) => {
// These errors are often like "the file doesn't exist yet" so we don't want
// them to be noisy.
tracing::debug!("{err:?}");
}
}

tracing::debug!("Waiting {duration:?} before retrying");
tokio::time::sleep(duration).await;
}

Ok(())
}

async fn run_inner(&mut self) -> miette::Result<()> {
loop {
tokio::select! {
result = Self::read(&self.path) => {
result?;
}
_ = self.shutdown.on_shutdown_requested() => {
// Graceful exit.
break;
}
else => {
// Graceful exit.
break;
}
}
}
Ok(())
}

async fn read(path: &Utf8Path) -> miette::Result<()> {
let file = File::open(&path).await.into_diagnostic()?;
let mut metadata = file.metadata().await.into_diagnostic()?;
let mut size = metadata.len();
let mut reader = BufReader::new(file);

if size > MAX_BYTES_PRINT_FROM_END {
tracing::debug!("Log file too big, skipping to end");
reader
.seek(SeekFrom::End(
-MAX_BYTES_PRINT_FROM_END
.try_conv::<i64>()
.expect("Constant is not bigger than i64::MAX"),
))
.await
.into_diagnostic()?;
}

let mut lines = reader.lines();

let mut backoff = ExponentialBackoff {
max_elapsed_time: None,
max_interval: Duration::from_millis(1000),
..Default::default()
};

let mut stdout = tokio::io::stdout();

while let Some(duration) = backoff.next_backoff() {
while let Some(line) = lines.next_line().await.into_diagnostic()? {
// TODO: Lock stdout here and for ghci output.
let _ = stdout.write_all(line.as_bytes()).await;
let _ = stdout.write_all(b"\n").await;
Comment on lines +117 to +119
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a pretty big "TODO". std::io::Stdout is globally synchronized, but tokio::io::Stdout is not, so this could lead to interleaved output.

AFAICT there's no easy solution for this. The tokio devs suggested creating a task to manage stdout and sending it messages (strings to write) over a channel, which is reasonable enough, but then I'll have to write an adapter to implement std::io::Write by sending messages to an async channel.

}

// Note: This will fail if the file has been removed. The inode/device number check is
// a secondary heuristic.
let new_metadata = fs::metadata(&path).await.into_diagnostic()?;
#[cfg(unix)]
if new_metadata.dev() != metadata.dev() || new_metadata.ino() != metadata.ino() {
return Err(miette!("Log file was replaced or removed: {path}"));
}

let new_size = new_metadata.len();
if new_size < size {
tracing::info!(%path, "Log file truncated");
let mut reader = lines.into_inner();
reader.seek(SeekFrom::Start(0)).await.into_diagnostic()?;
lines = reader.lines();
}
size = new_size;
metadata = new_metadata;

tracing::trace!("Caught up to log file");

tracing::trace!("Waiting {duration:?} before retrying");
tokio::time::sleep(duration).await;
}

Ok(())
}
}