Skip to content

Commit ba003e7

Browse files
committed
Add --read-logs-from for asynchronous log output
1 parent c08b446 commit ba003e7

File tree

8 files changed

+193
-2
lines changed

8 files changed

+193
-2
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ clearscreen = "2.0.1"
4444
command-group = { version = "2.1.0", features = ["tokio", "with-tokio"] }
4545
crossterm = { version = "0.27.0", features = ["event-stream"] }
4646
enum-iterator = "1.4.1"
47+
fs-err = { version = "2.11.0", features = ["tokio"] }
4748
humantime = "2.1.0"
4849
ignore = "0.4.20"
4950
indoc = "1.0.6"

src/cli.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ pub struct Opts {
6060
#[arg(long)]
6161
pub no_interrupt_reloads: bool,
6262

63+
/// Extra paths to read logs from.
64+
///
65+
/// `ghciwatch` needs to parse `ghci`'s output to determine when reloads have finished and to
66+
/// parse compiler errors, so libraries like Yesod that asynchronously print to stdout or
67+
/// stderr are not supported.
68+
///
69+
/// Instead, you should have your program write its logs to a file and use its path as an
70+
/// argument to this option. `ghciwatch` will read from the file and output logs inline with
71+
/// the rest of its output.
72+
///
73+
/// See: https://github.com/ndmitchell/ghcid/issues/137
74+
#[allow(rustdoc::bare_urls)]
75+
#[arg(long)]
76+
pub read_logs_from: Vec<Utf8PathBuf>,
77+
6378
/// Enable TUI mode (experimental).
6479
#[arg(long, hide = true)]
6580
pub tui: bool,

src/ghci/error_log.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use camino::Utf8PathBuf;
2+
use fs_err::tokio::File;
23
use miette::IntoDiagnostic;
3-
use tokio::fs::File;
44
use tokio::io::AsyncWriteExt;
55
use tokio::io::BufWriter;
66
use tracing::instrument;

src/ghci/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
2020
use aho_corasick::AhoCorasick;
2121
use camino::Utf8Path;
2222
use camino::Utf8PathBuf;
23+
use fs_err::tokio as fs;
2324
use miette::miette;
2425
use miette::IntoDiagnostic;
2526
use miette::WrapErr;
@@ -641,7 +642,7 @@ impl Ghci {
641642
/// Read and parse eval commands from the given `path`.
642643
#[instrument(level = "trace")]
643644
async fn parse_eval_commands(path: &Utf8Path) -> miette::Result<Vec<EvalCommand>> {
644-
let contents = tokio::fs::read_to_string(path)
645+
let contents = fs::read_to_string(path)
645646
.await
646647
.into_diagnostic()
647648
.wrap_err_with(|| format!("Failed to read {path}"))?;

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod ignore;
2828
mod incremental_reader;
2929
mod maybe_async_command;
3030
mod normal_path;
31+
mod read_logs_from;
3132
mod shutdown;
3233
mod string_case;
3334
mod tracing;
@@ -43,6 +44,7 @@ pub use ghci::manager::run_ghci;
4344
pub use ghci::Ghci;
4445
pub use ghci::GhciOpts;
4546
pub use ghci::GhciWriter;
47+
pub use read_logs_from::ReadLogsFrom;
4648
pub use shutdown::ShutdownError;
4749
pub use shutdown::ShutdownHandle;
4850
pub use shutdown::ShutdownManager;

src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use ghciwatch::run_ghci;
1212
use ghciwatch::run_tui;
1313
use ghciwatch::run_watcher;
1414
use ghciwatch::GhciOpts;
15+
use ghciwatch::ReadLogsFrom;
1516
use ghciwatch::ShutdownManager;
1617
use ghciwatch::TracingOpts;
1718
use ghciwatch::WatcherOpts;
@@ -45,6 +46,18 @@ async fn main() -> miette::Result<()> {
4546
.await;
4647
}
4748

49+
for path in opts.read_logs_from {
50+
manager
51+
.spawn("read-logs", |handle| {
52+
ReadLogsFrom {
53+
shutdown: handle,
54+
path,
55+
}
56+
.run()
57+
})
58+
.await;
59+
}
60+
4861
manager
4962
.spawn("run_ghci", |handle| {
5063
run_ghci(handle, ghci_opts, ghci_receiver)

src/read_logs_from.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
use std::io::SeekFrom;
2+
#[cfg(unix)]
3+
use std::os::unix::fs::MetadataExt;
4+
use std::time::Duration;
5+
6+
use backoff::backoff::Backoff;
7+
use backoff::ExponentialBackoff;
8+
use camino::Utf8Path;
9+
use camino::Utf8PathBuf;
10+
use fs_err::tokio as fs;
11+
use fs_err::tokio::File;
12+
use miette::miette;
13+
use miette::IntoDiagnostic;
14+
use tap::TryConv;
15+
use tokio::io::AsyncBufReadExt;
16+
use tokio::io::AsyncSeekExt;
17+
use tokio::io::AsyncWriteExt;
18+
use tokio::io::BufReader;
19+
use tracing::instrument;
20+
21+
use crate::ShutdownHandle;
22+
23+
/// Maximum number of bytes to print near the end of a log file, if it already has data when it's
24+
/// opened.
25+
const MAX_BYTES_PRINT_FROM_END: u64 = 0x200; // = 512
26+
27+
/// Me: Can we have `tail(1)`?
28+
///
29+
/// `ghciwatch`: We have `tail(1)` at home.
30+
///
31+
/// `tail(1)` at home:
32+
pub struct ReadLogsFrom {
33+
/// Shutdown handle.
34+
pub shutdown: ShutdownHandle,
35+
/// Path to read logs from.
36+
pub path: Utf8PathBuf,
37+
}
38+
39+
impl ReadLogsFrom {
40+
/// Read logs from the given path and output them to stdout.
41+
#[instrument(skip_all, name = "read-logs", level = "debug", fields(path = %self.path))]
42+
pub async fn run(mut self) -> miette::Result<()> {
43+
let mut backoff = ExponentialBackoff {
44+
max_elapsed_time: None,
45+
max_interval: Duration::from_secs(1),
46+
..Default::default()
47+
};
48+
while let Some(duration) = backoff.next_backoff() {
49+
match self.run_inner().await {
50+
Ok(()) => {
51+
// Graceful exit.
52+
break;
53+
}
54+
Err(err) => {
55+
// These errors are often like "the file doesn't exist yet" so we don't want
56+
// them to be noisy.
57+
tracing::debug!("{err:?}");
58+
}
59+
}
60+
61+
tracing::debug!("Waiting {duration:?} before retrying");
62+
tokio::time::sleep(duration).await;
63+
}
64+
65+
Ok(())
66+
}
67+
68+
async fn run_inner(&mut self) -> miette::Result<()> {
69+
loop {
70+
tokio::select! {
71+
result = Self::read(&self.path) => {
72+
result?;
73+
}
74+
_ = self.shutdown.on_shutdown_requested() => {
75+
// Graceful exit.
76+
break;
77+
}
78+
else => {
79+
// Graceful exit.
80+
break;
81+
}
82+
}
83+
}
84+
Ok(())
85+
}
86+
87+
async fn read(path: &Utf8Path) -> miette::Result<()> {
88+
let file = File::open(&path).await.into_diagnostic()?;
89+
let mut metadata = file.metadata().await.into_diagnostic()?;
90+
let mut size = metadata.len();
91+
let mut reader = BufReader::new(file);
92+
93+
if size > MAX_BYTES_PRINT_FROM_END {
94+
tracing::debug!("Log file too big, skipping to end");
95+
reader
96+
.seek(SeekFrom::End(
97+
-MAX_BYTES_PRINT_FROM_END
98+
.try_conv::<i64>()
99+
.expect("Constant is not bigger than i64::MAX"),
100+
))
101+
.await
102+
.into_diagnostic()?;
103+
}
104+
105+
let mut lines = reader.lines();
106+
107+
let mut backoff = ExponentialBackoff {
108+
max_elapsed_time: None,
109+
max_interval: Duration::from_millis(1000),
110+
..Default::default()
111+
};
112+
113+
let mut stdout = tokio::io::stdout();
114+
115+
while let Some(duration) = backoff.next_backoff() {
116+
while let Some(line) = lines.next_line().await.into_diagnostic()? {
117+
// TODO: Lock stdout here and for ghci output.
118+
let _ = stdout.write_all(line.as_bytes()).await;
119+
let _ = stdout.write_all(b"\n").await;
120+
}
121+
122+
// Note: This will fail if the file has been removed. The inode/device number check is
123+
// a secondary heuristic.
124+
let new_metadata = fs::metadata(&path).await.into_diagnostic()?;
125+
#[cfg(unix)]
126+
if new_metadata.dev() != metadata.dev() || new_metadata.ino() != metadata.ino() {
127+
return Err(miette!("Log file was replaced or removed: {path}"));
128+
}
129+
130+
let new_size = new_metadata.len();
131+
if new_size < size {
132+
tracing::info!(%path, "Log file truncated");
133+
let mut reader = lines.into_inner();
134+
reader.seek(SeekFrom::Start(0)).await.into_diagnostic()?;
135+
lines = reader.lines();
136+
}
137+
size = new_size;
138+
metadata = new_metadata;
139+
140+
tracing::trace!("Caught up to log file");
141+
142+
tracing::trace!("Waiting {duration:?} before retrying");
143+
tokio::time::sleep(duration).await;
144+
}
145+
146+
Ok(())
147+
}
148+
}

0 commit comments

Comments
 (0)