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
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,13 @@ format: format-rust format-lua format-ts

lint-rust:
cargo clippy --workspace --no-default-features --features zlob -- -D warnings
# Prefer luacheck from PATH (what CI uses). The luarocks shim is a fallback:
# it hardcodes the lua binary it was generated against and breaks whenever
# the interpreter is upgraded.
LUACHECK ?= $(shell command -v luacheck 2>/dev/null || echo ~/.luarocks/bin/luacheck)

lint-lua:
~/.luarocks/bin/luacheck .
$(LUACHECK) .
lint-ts:
bun lint

Expand Down
30 changes: 27 additions & 3 deletions crates/fff-c/include/fff.h
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,12 @@ typedef struct FffMixedSearchResult {

/**
* A single watch event. `kind`: 0 = created, 1 = modified, 2 = removed,
* 3 = rescan (events were lost; re-stat what you care about).
* 3 = rescan (events were lost; re-stat what you care about),
* 4 = renamed (`path` is the destination; read the source with
* `fff_watch_events_get_from_path`).
*
* Layout is frozen: this is an array element, so growing it would change the
* stride every existing binding compiled against.
*/
typedef struct FffWatchEvent {
/**
Expand All @@ -426,10 +431,17 @@ typedef struct FffWatchEvent {

/**
* A batch of watch events. Free with `fff_free_watch_events`.
* Versioned by append only: existing field offsets never move.
*/
typedef struct FffWatchEventBatch {
struct FffWatchEvent *events;
uint32_t count;
/**
* Parallel to `events`, `count` long: the pre-rename path for entries
* with `kind == 4`, null for every other entry. Null when the batch holds
* no renames at all.
*/
char **rename_sources;
} FffWatchEventBatch;

/**
Expand Down Expand Up @@ -1321,7 +1333,7 @@ struct FffResult *fff_watch_args(void *fff_handle,
struct FffResult *fff_unwatch(void *fff_handle, uint64_t watch_id);

/**
* Number of events in a batch; 0 if `batch` is null.
* Number of events in a batch, 0 if `batch` is null.
*
* ## Safety
* `batch` must be a valid `FffWatchEventBatch` pointer or null.
Expand All @@ -1337,10 +1349,22 @@ uint32_t fff_watch_events_count(const struct FffWatchEventBatch *batch);
const char *fff_watch_events_get_path(const struct FffWatchEventBatch *batch, uint32_t index);

/**
* Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan)
* Pre-rename path of event `index`, null unless its kind is 4 (renamed).
* Owned by the batch; do not free separately.
*
* ## Safety
* `batch` must be a valid `FffWatchEventBatch` pointer or null.
*/
const char *fff_watch_events_get_from_path(const struct FffWatchEventBatch *batch, uint32_t index);

/**
* Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan,
* 4 = renamed).
* 3 (rescan aka "re-stat something" kind) returned when OS based buffer
* has been overflown and some events might be loss. Paths will contain a list of
* directories that needs to be rescanned to ensure consistency.
* 4 reports a move: `path` is the destination and
* `fff_watch_events_get_from_path` yields the source.
*
* ## Safety
* `batch` must be a valid `FffWatchEventBatch` pointer or null.
Expand Down
95 changes: 81 additions & 14 deletions crates/fff-c/src/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ pub struct FffWatchOptions {
}

/// A single watch event. `kind`: 0 = created, 1 = modified, 2 = removed,
/// 3 = rescan (events were lost; re-stat what you care about).
/// 3 = rescan (events were lost; re-stat what you care about),
/// 4 = renamed (`path` is the destination; read the source with
/// `fff_watch_events_get_from_path`).
///
/// Layout is frozen: this is an array element, so growing it would change the
/// stride every existing binding compiled against.
#[repr(C)]
pub struct FffWatchEvent {
/// Absolute path (heap C string owned by the parent batch).
Expand All @@ -34,10 +39,15 @@ pub struct FffWatchEvent {
}

/// A batch of watch events. Free with `fff_free_watch_events`.
/// Versioned by append only: existing field offsets never move.
#[repr(C)]
pub struct FffWatchEventBatch {
pub events: *mut FffWatchEvent,
pub count: u32,
/// Parallel to `events`, `count` long: the pre-rename path for entries
/// with `kind == 4`, null for every other entry. Null when the batch holds
/// no renames at all.
pub rename_sources: *mut *mut c_char,
}

/// Instance-wide callback invoked with `(watch_id, batch)` for every `fff_watch`
Expand All @@ -49,29 +59,51 @@ fn batch_into_raw(events: &[WatchEvent]) -> *mut FffWatchEventBatch {
let items: Vec<FffWatchEvent> = events
.iter()
.map(|ev| FffWatchEvent {
path: CString::new(ev.path.to_string_lossy().as_bytes())
.unwrap_or_default()
.into_raw(),
path: path_into_raw(&ev.path),
kind: ev.kind as u8,
})
.collect();

let count = items.len() as u32;
let events_ptr = if items.is_empty() {
ptr::null_mut()
let events_ptr = leak_slice(items);

let rename_sources = if events.iter().any(|ev| ev.from.is_some()) {
let sources: Vec<*mut c_char> = events
.iter()
.map(|ev| {
ev.from
.as_ref()
.map_or(ptr::null_mut(), |p| path_into_raw(p))
})
.collect();
leak_slice(sources)
} else {
let mut boxed = items.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
p
ptr::null_mut()
};

Box::into_raw(Box::new(FffWatchEventBatch {
events: events_ptr,
count,
rename_sources,
}))
}

fn path_into_raw(path: &std::path::Path) -> *mut c_char {
CString::new(path.to_string_lossy().as_bytes())
.unwrap_or_default()
.into_raw()
}

fn leak_slice<T>(items: Vec<T>) -> *mut T {
if items.is_empty() {
return ptr::null_mut();
}
let mut boxed = items.into_boxed_slice();
let p = boxed.as_mut_ptr();
std::mem::forget(boxed);
p
}

unsafe fn watch_options_from_ffi(
opts: *const FffWatchOptions,
) -> Result<WatchOptions, *mut FffResult> {
Expand Down Expand Up @@ -270,10 +302,33 @@ pub unsafe extern "C" fn fff_watch_events_get_path(
}
}

/// Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan)
/// Pre-rename path of event `index`, null unless its kind is 4 (renamed).
/// Owned by the batch; do not free separately.
///
/// ## Safety
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fff_watch_events_get_from_path(
batch: *const FffWatchEventBatch,
index: u32,
) -> *const c_char {
if batch.is_null() {
return ptr::null();
}
let batch = unsafe { &*batch };
if batch.rename_sources.is_null() || index >= batch.count {
return ptr::null();
}
unsafe { *batch.rename_sources.add(index as usize) }
}

/// Kind of event `index` (0 = created, 1 = modified, 2 = removed, 3 = rescan,
/// 4 = renamed).
/// 3 (rescan aka "re-stat something" kind) returned when OS based buffer
/// has been overflown and some events might be loss. Paths will contain a list of
/// directories that needs to be rescanned to ensure consistency.
/// 4 reports a move: `path` is the destination and
/// `fff_watch_events_get_from_path` yields the source.
///
/// ## Safety
/// `batch` must be a valid `FffWatchEventBatch` pointer or null.
Expand Down Expand Up @@ -313,15 +368,23 @@ pub unsafe extern "C" fn fff_free_watch_events(batch: *mut FffWatchEventBatch) {
}
unsafe {
let batch = Box::from_raw(batch);
let count = batch.count as usize;
if !batch.events.is_null() {
let events =
Vec::from_raw_parts(batch.events, batch.count as usize, batch.count as usize);
let events = Vec::from_raw_parts(batch.events, count, count);
for ev in events {
if !ev.path.is_null() {
drop(CString::from_raw(ev.path));
}
}
}
if !batch.rename_sources.is_null() {
let sources = Vec::from_raw_parts(batch.rename_sources, count, count);
for source in sources {
if !source.is_null() {
drop(CString::from_raw(source));
}
}
}
}
}

Expand All @@ -344,8 +407,12 @@ mod layout_tests {
assert_eq!(offset_of!(FffWatchEvent, path), 0);
assert_eq!(offset_of!(FffWatchEvent, kind), 8);

assert_eq!(size_of::<FffWatchEventBatch>(), 16);
assert_eq!(offset_of!(FffWatchEventBatch, events), 0);
assert_eq!(offset_of!(FffWatchEventBatch, count), 8);
// Appended in the renamed-event release. The batch is a single
// library-allocated struct reached only through a pointer, so growing
// its tail leaves every previously published offset valid.
assert_eq!(size_of::<FffWatchEventBatch>(), 24);
assert_eq!(offset_of!(FffWatchEventBatch, rename_sources), 16);
}
}
45 changes: 43 additions & 2 deletions crates/fff-c/tests/smoke.c
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ static int watch_glob_hits = 0;
static int watch_dir_hits = 0;
static int watch_all_hits = 0;
static int watch_ignored_leaks = 0;
static int watch_rename_hits = 0;
static int watch_rename_bad_source = 0;
static uint64_t watch_glob_id = 0;
static uint64_t watch_dir_id = 0;
static uint64_t watch_all_id = 0;
Expand All @@ -47,6 +49,16 @@ static void on_watch_batch(uint64_t watch_id, struct FffWatchEventBatch *batch,
if (watch_id == watch_all_id && strstr(path, "hello.txt")) {
watch_all_hits++;
}
/* kind 4 = renamed: the source rides in the parallel array */
if (watch_id == watch_all_id && fff_watch_events_get_kind(batch, i) == 4 &&
strstr(path, "renamed.txt")) {
const char *from = fff_watch_events_get_from_path(batch, i);
if (from && strstr(from, "to_rename.txt")) {
watch_rename_hits++;
} else {
watch_rename_bad_source++;
}
}
}

fff_free_watch_events(batch); // need to clean dynamic array of events
Expand Down Expand Up @@ -156,6 +168,26 @@ static int watch_smoke(void) {
usleep(100 * 1000);
}

/* a rename must arrive as one kind-4 event carrying both paths */
char rename_src[512];
char rename_dst[512];
snprintf(rename_src, sizeof(rename_src), "%s/to_rename.txt", dir);
snprintf(rename_dst, sizeof(rename_dst), "%s/renamed.txt", dir);
FILE *rf = fopen(rename_src, "w");
if (rf) {
fputs("move me\n", rf);
fclose(rf);
}
usleep(500 * 1000); /* let the create land before the move */
if (rename(rename_src, rename_dst) != 0) {
fprintf(stderr, "watch_smoke: rename failed\n");
fff_destroy(picker);
return 1;
}
for (int attempt = 0; attempt < 100 && watch_rename_hits == 0; attempt++) {
usleep(100 * 1000);
}

r = fff_unwatch(picker, watch_glob_id);
fff_free_result(r);
r = fff_unwatch(picker, watch_dir_id);
Expand Down Expand Up @@ -191,9 +223,18 @@ static int watch_smoke(void) {
fprintf(stderr, "watch_smoke FAIL: repeated unwatch was not a no-op\n");
return 1;
}
if (watch_rename_hits == 0) {
fprintf(stderr, "watch_smoke FAIL: no renamed event with a source path\n");
return 1;
}
if (watch_rename_bad_source > 0) {
fprintf(stderr, "watch_smoke FAIL: %d renamed events had a wrong source\n",
watch_rename_bad_source);
return 1;
}

fprintf(stderr, "watch_smoke PASS (glob=%d dir=%d all=%d)\n", watch_glob_hits, watch_dir_hits,
watch_all_hits);
fprintf(stderr, "watch_smoke PASS (glob=%d dir=%d all=%d rename=%d)\n", watch_glob_hits,
watch_dir_hits, watch_all_hits, watch_rename_hits);
return 0;
}

Expand Down
62 changes: 62 additions & 0 deletions crates/fff-core/src/dbs/frecency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ impl FrecencyTracker {
);
return Ok(());
}

return Err(Error::DbWrite {
db: Self::LABEL,
source: e,
Expand All @@ -313,6 +314,67 @@ impl FrecencyTracker {
})
}

pub fn copy_history(&self, from: &Path, to: &Path) -> Result<bool> {
if from == to {
return Ok(false);
}

let Some(source) = self.get_accesses(from)?.filter(|a| !a.is_empty()) else {
return Ok(false);
};

let target_key = Self::path_to_hash_bytes(to)?;
let target = self.get_accesses(to)?.unwrap_or_default();

let mut timestamps: Vec<u64> = source.iter().chain(target.iter()).copied().collect();
timestamps.sort_unstable();
let overflow = timestamps.len().saturating_sub(MAX_TIMESTAMPS_PER_FILE);
let merged: VecDeque<u64> = timestamps.drain(overflow..).collect();

tracing::debug!(
?from,
?to,
accesses = merged.len(),
"Copying frecency history"
);

let mut wtxn = self
.env
.write_txn()
.map_err(|source| Error::DbStartWriteTxn {
db: Self::LABEL,
source,
})?;
if let Err(e) = self.db.put(&mut wtxn, &target_key, &merged) {
if is_map_full(&e) {
self.health.mark_unhealthy("MDB_MAP_FULL on put");
tracing::error!(?to, "Frecency DB hit MDB_MAP_FULL; dropping history copy");
return Ok(false);
}
return Err(Error::DbWrite {
db: Self::LABEL,
source: e,
});
}

if let Err(e) = wtxn.commit() {
if is_map_full(&e) {
self.health.mark_unhealthy("MDB_MAP_FULL on commit");
tracing::error!(
?to,
"Frecency DB hit MDB_MAP_FULL on commit; dropping history copy"
);
return Ok(false);
}
return Err(Error::DbCommit {
db: Self::LABEL,
source: e,
});
}

Ok(true)
}

pub fn get_access_score(&self, file_path: &Path, mode: FFFMode) -> i64 {
let accesses = self
.get_accesses(file_path)
Expand Down
Loading
Loading