Skip to content

Commit

Permalink
Inotify
Browse files Browse the repository at this point in the history
  • Loading branch information
lhchavez committed Aug 16, 2024
1 parent 9b65d88 commit b632d5a
Show file tree
Hide file tree
Showing 6 changed files with 439 additions and 7 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ libc = "0.2.152"
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.12.2", default-features = false, features = ["napi4"] }
napi-derive = "2.12.2"
nix = { version = "0.29.0", features = ["fs", "term", "poll"] }
nix = { version = "0.29.0", features = ["fs", "term", "poll", "inotify"] }

[build-dependencies]
napi-build = "2.0.1"
Expand Down
15 changes: 15 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export function setCloseOnExec(fd: number, closeOnExec: boolean): void
*_CLOEXEC` under the covers.
*/
export function getCloseOnExec(fd: number): boolean
export const IN_CLOSE_WRITE: number
export const IN_MOVED_FROM: number
export const IN_MOVED_TO: number
export const IN_DELETE: number
export class Pty {
/** The pid of the forked process. */
pid: number
Expand All @@ -41,3 +45,14 @@ export class Pty {
*/
takeFd(): c_int
}
/**
* A way to access Linux' `inotify(7)` subsystem. For simplicity, this only allows subscribing for
* events on directories (instead of files) and only for modify-close and rename events.
*/
export class Inotify {
constructor()
close(): void
fd(): c_int
addCloseWrite(dir: string): number
removeWatch(wd: number): void
}
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,14 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}

const { Pty, ptyResize, setCloseOnExec, getCloseOnExec } = nativeBinding
const { Pty, ptyResize, setCloseOnExec, getCloseOnExec, Inotify, IN_CLOSE_WRITE, IN_MOVED_FROM, IN_MOVED_TO, IN_DELETE } = nativeBinding

module.exports.Pty = Pty
module.exports.ptyResize = ptyResize
module.exports.setCloseOnExec = setCloseOnExec
module.exports.getCloseOnExec = getCloseOnExec
module.exports.Inotify = Inotify
module.exports.IN_CLOSE_WRITE = IN_CLOSE_WRITE
module.exports.IN_MOVED_FROM = IN_MOVED_FROM
module.exports.IN_MOVED_TO = IN_MOVED_TO
module.exports.IN_DELETE = IN_DELETE
220 changes: 220 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,223 @@ fn set_nonblocking(fd: i32) -> Result<(), napi::Error> {
}
Ok(())
}

#[cfg(target_os = "linux")]
use nix::sys::inotify::{AddWatchFlags, InitFlags, Inotify as NixInotify};
#[cfg(target_os = "linux")]
use std::ffi::CString;
#[cfg(target_os = "linux")]
use std::os::fd::AsFd;

/// A way to access Linux' `inotify(7)` subsystem. For simplicity, this only allows subscribing for
/// events on directories (instead of files) and only for modify-close and rename events.
#[cfg(target_os = "linux")]
#[napi]
#[allow(dead_code)]
struct Inotify {
inotify: Option<NixInotify>,
}

#[cfg(target_os = "linux")]
#[napi]
#[allow(dead_code)]
impl Inotify {
#[napi(constructor)]
pub fn new() -> Result<Self, napi::Error> {
let inotify = match NixInotify::init(InitFlags::IN_CLOEXEC | InitFlags::IN_NONBLOCK) {
Ok(inotify) => inotify,
Err(err) => {
return Err(napi::Error::new(
GenericFailure,
format!("inotify_init: {}", err),
));
}
};
Ok(Inotify {
inotify: Some(inotify),
})
}

#[napi]
#[allow(dead_code)]
pub fn close(&mut self) -> Result<(), napi::Error> {
let inotify = self.inotify.take();
if inotify.is_none() {
return Err(napi::Error::new(
GenericFailure,
"inotify fd has already been closed",
));
}

Ok(())
}

#[napi]
#[allow(dead_code)]
pub fn fd(&mut self) -> Result<c_int, napi::Error> {
if let Some(inotify) = &self.inotify {
match inotify.as_fd().try_clone_to_owned() {
Ok(fd) => Ok(fd.into_raw_fd()),
Err(err) => Err(napi::Error::new(
GenericFailure,
format!("inotify_init: {}", err),
)),
}
} else {
Err(napi::Error::new(
GenericFailure,
"inotify fd has already been closed",
))
}
}

#[napi]
#[allow(dead_code)]
pub fn add_close_write(&mut self, dir: String) -> Result<i32, napi::Error> {
let cstring_dir = match CString::new(dir.as_str()) {
Ok(cstring_dir) => cstring_dir,
Err(err) => {
return Err(napi::Error::new(
GenericFailure,
format!("CString::new: {}", err),
));
}
};
if let Some(inotify) = &self.inotify {
match Errno::result(unsafe {
libc::inotify_add_watch(
inotify.as_fd().as_raw_fd(),
cstring_dir.as_c_str().as_ptr(),
(AddWatchFlags::IN_CLOSE_WRITE | AddWatchFlags::IN_MOVED_TO | AddWatchFlags::IN_DELETE)
.bits(),
)
}) {
Ok(wd) => Ok(wd),
Err(err) => Err(napi::Error::new(
GenericFailure,
format!("inotify_add_watch: {}", err),
)),
}
} else {
Err(napi::Error::new(
GenericFailure,
"inotify fd has already been closed",
))
}
}

#[napi]
#[allow(dead_code)]
pub fn remove_watch(&mut self, wd: i32) -> Result<(), napi::Error> {
if let Some(inotify) = &self.inotify {
if let Err(err) =
Errno::result(unsafe { libc::inotify_rm_watch(inotify.as_fd().as_raw_fd(), wd) })
{
Err(napi::Error::new(
GenericFailure,
format!("inotify_remove_watch: {}", err),
))
} else {
Ok(())
}
} else {
Err(napi::Error::new(
GenericFailure,
"inotify fd has already been closed",
))
}
}
}

#[cfg(target_os = "linux")]
#[napi]
#[allow(dead_code)]
pub const IN_CLOSE_WRITE: u32 = AddWatchFlags::IN_CLOSE_WRITE.bits();

#[cfg(target_os = "linux")]
#[napi]
#[allow(dead_code)]
pub const IN_MOVED_FROM: u32 = AddWatchFlags::IN_MOVED_FROM.bits();

#[cfg(target_os = "linux")]
#[napi]
#[allow(dead_code)]
pub const IN_MOVED_TO: u32 = AddWatchFlags::IN_MOVED_TO.bits();

#[cfg(target_os = "linux")]
#[napi]
#[allow(dead_code)]
pub const IN_DELETE: u32 = AddWatchFlags::IN_DELETE.bits();

#[cfg(not(target_os = "linux"))]
#[napi]
struct Inotify {}

#[cfg(not(target_os = "linux"))]
#[napi]
impl Inotify {
#[napi(constructor)]
#[allow(dead_code)]
pub fn new() -> Result<Self, napi::Error> {
Err(napi::Error::new(
GenericFailure,
format!("inotify not supported in non-Linux"),
))
}

#[napi]
#[allow(dead_code)]
pub fn close(&mut self) -> Result<(), napi::Error> {
Err(napi::Error::new(
GenericFailure,
format!("inotify not supported in non-Linux"),
))
}

#[napi]
#[allow(dead_code)]
pub fn fd(&mut self) -> Result<c_int, napi::Error> {
Err(napi::Error::new(
GenericFailure,
format!("inotify not supported in non-Linux"),
))
}

#[napi]
#[allow(dead_code)]
pub fn add_close_write(&mut self, dir: String) -> Result<i32, napi::Error> {
Err(napi::Error::new(
GenericFailure,
format!("inotify not supported in non-Linux"),
))
}

#[napi]
#[allow(dead_code)]
pub fn remove_watch(&mut self, wd: i32) -> Result<(), napi::Error> {
Err(napi::Error::new(
GenericFailure,
format!("inotify not supported in non-Linux"),
))
}
}

#[cfg(not(target_os = "linux"))]
#[napi]
#[allow(dead_code)]
pub const IN_CLOSE_WRITE: u32 = 0;

#[cfg(not(target_os = "linux"))]
#[napi]
#[allow(dead_code)]
pub const IN_MOVED_FROM: u32 = 0;

#[cfg(not(target_os = "linux"))]
#[napi]
#[allow(dead_code)]
pub const IN_MOVED_TO: u32 = 0;

#[cfg(not(target_os = "linux"))]
#[napi]
#[allow(dead_code)]
pub const IN_DELETE: u32 = 0;
91 changes: 87 additions & 4 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Pty, getCloseOnExec, setCloseOnExec } from '../wrapper';
import { type Writable } from 'stream';
import { readdirSync, readlinkSync } from 'fs';
import { describe, test, expect } from 'vitest';
import { Pty, getCloseOnExec, setCloseOnExec, Inotify } from '../wrapper';
import { type Writable } from 'node:stream';
import { readdirSync, readlinkSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { beforeEach, afterEach, describe, test, expect, vi } from 'vitest';

const EOT = '\x04';
const procSelfFd = '/proc/self/fd/';
Expand Down Expand Up @@ -428,3 +430,84 @@ describe('setCloseOnExec', () => {
setCloseOnExec(0, originalFlag);
});
});

describe(
'Inotify',
() => {
let tmpdir: string;
beforeEach(async () => {
tmpdir = await mkdtemp('/tmp/inotify');
});
afterEach(async () => {
await rm(tmpdir, { recursive: true }).catch(() => {});
});

testSkipOnDarwin('can watch existent files', async () => {
const inotify = new Inotify();
try {
const events: Array<'modify' | 'delete'> = [];
const fullPath = join(tmpdir, 'mycoolfile.txt');
await writeFile(fullPath, 'hi!');

const dispose = inotify.watch(fullPath, (event) => {
events.push(event);
});

await writeFile(fullPath, 'bye!');

vi.waitFor(() => {
expect(events).toEqual(['modify']);

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 459 in tests/index.test.ts

View workflow job for this annotation

GitHub Actions / Build and test on x86_64-unknown-linux-gnu

Unhandled error

AssertionError: expected [] to deeply equal [ 'modify' ] - Expected + Received - Array [ - "modify", - ] + Array [] ❯ tests/index.test.ts:459:26 ❯ Timeout.checkCallback [as _onTimeout] node_modules/vitest/dist/vendor/vi.YFlodzP_.js:3237:24 ❯ listOnTimeout node:internal/timers:581:17 ❯ processTimers node:internal/timers:519:7 This error originated in "tests/index.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "can watch inexistent files". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.
});

dispose();
} finally {
inotify.close();
}
});

testSkipOnDarwin('can watch inexistent files', async () => {
const inotify = new Inotify();
try {
const events: Array<'modify' | 'delete'> = [];
const fullPath = join(tmpdir, 'mycoolfile.txt');
const dispose = inotify.watch(fullPath, (event) => {
events.push(event);
});

await writeFile(fullPath, 'hi!');
await writeFile(fullPath, 'bye!');

vi.waitFor(() => {
expect(events).toEqual(['modify', 'modify']);
});

dispose();
} finally {
inotify.close();
}
});

testSkipOnDarwin('ignores unrelated events', async () => {
const inotify = new Inotify();
try {
const events: Array<'modify' | 'delete'> = [];
const fullPath = join(tmpdir, 'mycoolfile.txt');
const dispose = inotify.watch(fullPath, (event) => {
events.push(event);
});

await writeFile(fullPath + '.2', 'hi!');
await writeFile(fullPath, 'bye!');

vi.waitFor(() => {
expect(events).toEqual(['modify']);
});

dispose();
} finally {
inotify.close();
}
});
},
{ repeats: 50 },
);
Loading

0 comments on commit b632d5a

Please sign in to comment.