Skip to content
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
30 changes: 30 additions & 0 deletions docs/guides/file-watching.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ watch = ["src/**/*.ts", "package.json"]

When any `.ts` file in `src/` or `package.json` changes, the daemon will automatically restart.

You can also select the watcher backend per daemon:

```toml
[daemons.api]
run = "npm run dev"
watch = ["src/**/*.ts", "package.json"]
watch_mode = "auto" # native | poll | auto
```

- `native` (default): use OS-native notifications (inotify/FSEvents/etc.)
- `poll`: use polling file scans (works better on some NFS/remote mounts)
- `auto`: prefer native, automatically fall back to polling when native watch setup fails

## How It Works

1. **On supervisor start**: Pitchfork scans all daemons for `watch` patterns
Expand All @@ -22,6 +35,8 @@ When any `.ts` file in `src/` or `package.json` changes, the daemon will automat
4. **Pattern matching**: Changed files are matched against glob patterns
5. **Auto-restart**: Running daemons with matching patterns are automatically restarted

When `watch_mode = "poll"` (or `"auto"` falls back), polling interval is controlled by `settings.supervisor.watch_poll_interval`.

::: tip
Only running daemons are restarted. If a daemon is stopped, file changes won't start it.
:::
Expand Down Expand Up @@ -131,6 +146,21 @@ retry = 3 # Retry up to 3 times if restart fails
- **Recursive watching**: Subdirectories are watched automatically for `**` patterns
- **Running daemons only**: Stopped daemons ignore file changes

### Polling Tuning

Use settings to tune watcher behavior globally:

```toml
[settings.supervisor]
watch_poll_interval = "500ms"
watch_interval = "10s"
```

- `watch_poll_interval`: polling scan cadence for `watch_mode = "poll"` (and auto fallback)
- `watch_interval`: supervisor refresh cadence for watch config updates (new/removed watched daemons)

For remote development or network filesystems, values like `watch_poll_interval = "100ms"` to `"1s"` are common depending on CPU/IO budget.

## Troubleshooting

### Files not triggering restart
Expand Down
34 changes: 33 additions & 1 deletion docs/public/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@
"items": {
"type": "string"
}
},
"watch_mode": {
"description": "File watching backend mode.\n\n- `native`: use platform-native notifications (default)\n- `poll`: use polling-based watcher\n- `auto`: prefer native, fall back to polling if native watch fails",
"$ref": "#/$defs/WatchMode",
"default": "native"
}
},
"required": [
Expand Down Expand Up @@ -605,7 +610,14 @@
]
},
"watch_interval": {
"description": "File watcher poll/refresh interval",
"description": "File watcher config refresh interval",
"type": [
"string",
"null"
]
},
"watch_poll_interval": {
"description": "Polling watcher filesystem scan interval",
"type": [
"string",
"null"
Expand Down Expand Up @@ -703,6 +715,26 @@
]
}
}
},
"WatchMode": {
"description": "File watch backend mode for daemon `watch` patterns.",
"oneOf": [
{
"description": "Use platform-native watcher backend (inotify/FSEvents/ReadDirectoryChangesW).",
"type": "string",
"const": "native"
},
{
"description": "Use polling backend; more compatible on networked filesystems.",
"type": "string",
"const": "poll"
},
{
"description": "Prefer native backend, fall back to polling when native watch setup fails.",
"type": "string",
"const": "auto"
}
]
}
}
}
20 changes: 20 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,26 @@ watch = ["src/**/*.ts", "package.json"]

See [File Watching guide](/guides/file-watching) for more details.

### `watch_mode`

Select which file watcher backend to use for this daemon. Default: `"native"`

```toml
[daemons.api]
run = "npm run dev"
watch = ["src/**/*.ts", "package.json"]
watch_mode = "auto"
```

**Allowed values:**
- `"native"` - OS-native filesystem notifications (default)
- `"poll"` - Polling-based watcher (better compatibility on some NFS/remote mounts)
- `"auto"` - Prefer native, automatically fall back to polling if native watcher setup fails

**Related settings:**
- `settings.supervisor.watch_poll_interval` controls polling scan cadence
- `settings.supervisor.watch_interval` controls how often supervisor refreshes watch config state

### `expected_port`

TCP ports the daemon is expected to bind to. Used for port conflict detection before starting a daemon.
Expand Down
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ _.path = "target/debug"
depends = ["lint", "build", "docs:build"]
run = [
"cargo nextest run",
"git submodule update --init --recursive",
"test/bats/bin/bats test",
]

Expand Down
28 changes: 24 additions & 4 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -420,13 +420,33 @@ type = "Duration"
env = "PITCHFORK_WATCH_INTERVAL"
deprecated_env = "PITCHFORK_WATCH_INTERVAL_MS"
default = "10s"
description = "File watcher poll/refresh interval"
description = "File watcher config refresh interval"
docs = """
How often the file watcher checks for filesystem changes when using `watch` patterns.
How often the supervisor refreshes file watch configuration when using `watch` patterns.

This controls how quickly newly started/stopped daemons with watch patterns are reflected
in the active watcher set.

For polling watcher cadence, use `supervisor.watch_poll_interval`.

Lower values react faster to configuration/runtime changes but use more CPU.
The default `"10s"` is appropriate for most environments.
"""

[supervisor.watch_poll_interval]
type = "Duration"
env = "PITCHFORK_WATCH_POLL_INTERVAL"
default = "500ms"
description = "Polling watcher filesystem scan interval"
docs = """
How often polling-based file watchers scan for changes.

This applies when daemon `watch_mode` is `poll`, or when `watch_mode = "auto"`
falls back to polling because native watchers are unavailable.

Lower values detect changes faster but use more CPU and I/O.
A value of `"100ms"` is useful for tests or highly interactive workflows;
the default `"10s"` is appropriate for production.
`"100ms"` is useful for highly interactive workflows;
`"500ms"` is a practical default for remote/networked filesystems.
"""

[supervisor.http_client_timeout]
Expand Down
8 changes: 6 additions & 2 deletions src/cli/logs.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::daemon_id::DaemonId;
use crate::pitchfork_toml::PitchforkToml;
use crate::pitchfork_toml::{PitchforkToml, WatchMode};
use crate::state_file::StateFile;
use crate::ui::style::edim;
use crate::watch_files::WatchFiles;
Expand Down Expand Up @@ -1066,7 +1066,11 @@ fn get_log_file_infos(names: &[DaemonId]) -> Result<BTreeMap<DaemonId, LogFile>>

pub async fn tail_logs(names: &[DaemonId]) -> Result<()> {
let mut log_files = get_log_file_infos(names)?;
let mut wf = WatchFiles::new(Duration::from_millis(10))?;
let mut wf = WatchFiles::new(
Duration::from_millis(10),
WatchMode::Native,
Duration::from_millis(500),
)?;

for lf in log_files.values() {
wf.watch(&lf.path, RecursiveMode::NonRecursive)?;
Expand Down
9 changes: 8 additions & 1 deletion src/daemon.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::daemon_id::DaemonId;
use crate::daemon_status::DaemonStatus;
use crate::pitchfork_toml::{CpuLimit, CronRetrigger, MemoryLimit};
use crate::pitchfork_toml::{CpuLimit, CronRetrigger, MemoryLimit, WatchMode};
use indexmap::IndexMap;
use std::fmt::Display;
use std::path::PathBuf;
Expand Down Expand Up @@ -115,6 +115,8 @@ pub struct Daemon {
pub env: Option<IndexMap<String, String>>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub watch: Vec<String>,
#[serde(default)]
pub watch_mode: WatchMode,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub watch_base_dir: Option<PathBuf>,
/// Whether to use mise for this daemon (None = inherit global general.mise setting).
Expand Down Expand Up @@ -163,6 +165,8 @@ pub struct RunOptions {
pub env: Option<IndexMap<String, String>>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub watch: Vec<String>,
#[serde(default)]
pub watch_mode: WatchMode,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub watch_base_dir: Option<PathBuf>,
/// Whether to use mise for this daemon (None = inherit global general.mise setting).
Expand Down Expand Up @@ -217,6 +221,7 @@ impl Default for Daemon {
depends: Vec::new(),
env: None,
watch: Vec::new(),
watch_mode: WatchMode::default(),
watch_base_dir: None,
mise: None,
memory_limit: None,
Expand Down Expand Up @@ -254,6 +259,7 @@ impl Daemon {
depends: self.depends.clone(),
env: self.env.clone(),
watch: self.watch.clone(),
watch_mode: self.watch_mode,
watch_base_dir: self.watch_base_dir.clone(),
mise: self.mise,
slug: self.slug.clone(),
Expand Down Expand Up @@ -289,6 +295,7 @@ impl Default for RunOptions {
depends: Vec::new(),
env: None,
watch: Vec::new(),
watch_mode: WatchMode::default(),
watch_base_dir: None,
mise: None,
slug: None,
Expand Down
1 change: 1 addition & 0 deletions src/daemon_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ fn build_daemon_list(
depends: vec![],
env: None,
watch: vec![],
watch_mode: daemon_config.watch_mode,
watch_base_dir: None,
mise: daemon_config.mise,
active_port: None,
Expand Down
1 change: 1 addition & 0 deletions src/deps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ mod tests {
.map(|s| DaemonId::new("global", s))
.collect(),
watch: vec![],
watch_mode: crate::pitchfork_toml::WatchMode::default(),
dir: None,
env: None,
hooks: None,
Expand Down
31 changes: 31 additions & 0 deletions src/pitchfork_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ struct PitchforkTomlDaemonRaw {
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub watch: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub watch_mode: Option<WatchMode>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub env: Option<IndexMap<String, String>>,
Expand Down Expand Up @@ -870,6 +872,7 @@ impl PitchforkToml {
boot_start: raw_daemon.boot_start,
depends,
watch: raw_daemon.watch,
watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
dir: raw_daemon.dir,
env: raw_daemon.env,
hooks: raw_daemon.hooks,
Expand Down Expand Up @@ -981,6 +984,10 @@ impl PitchforkToml {
})
.collect(),
watch: daemon.watch.clone(),
watch_mode: match daemon.watch_mode {
WatchMode::Native => None,
mode => Some(mode),
},
dir: daemon.dir.clone(),
env: daemon.env.clone(),
hooks: daemon.hooks.clone(),
Expand Down Expand Up @@ -1178,6 +1185,13 @@ pub struct PitchforkTomlDaemon {
/// File patterns to watch for changes
#[schemars(default)]
pub watch: Vec<String>,
/// File watching backend mode.
///
/// - `native`: use platform-native notifications (default)
/// - `poll`: use polling-based watcher
/// - `auto`: prefer native, fall back to polling if native watch fails
#[schemars(default)]
pub watch_mode: WatchMode,
/// Working directory for the daemon. Relative paths are resolved from the pitchfork.toml location.
pub dir: Option<String>,
/// Environment variables to set for the daemon process
Expand Down Expand Up @@ -1215,6 +1229,7 @@ impl Default for PitchforkTomlDaemon {
boot_start: None,
depends: Vec::new(),
watch: Vec::new(),
watch_mode: WatchMode::default(),
dir: None,
env: None,
hooks: None,
Expand Down Expand Up @@ -1263,6 +1278,7 @@ impl PitchforkTomlDaemon {
depends: self.depends.clone(),
env: self.env.clone(),
watch: self.watch.clone(),
watch_mode: self.watch_mode,
watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
self.path.as_deref(),
)),
Expand All @@ -1285,6 +1301,21 @@ fn default_port_bump_attempts() -> u32 {
10
}

/// File watch backend mode for daemon `watch` patterns.
#[derive(
Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum WatchMode {
/// Use platform-native watcher backend (inotify/FSEvents/ReadDirectoryChangesW).
#[default]
Native,
/// Use polling backend; more compatible on networked filesystems.
Poll,
/// Prefer native backend, fall back to polling when native watch setup fails.
Auto,
}

/// Cron scheduling configuration
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
pub struct PitchforkTomlCron {
Expand Down
1 change: 1 addition & 0 deletions src/state_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ mod tests {
depends: vec![],
env: None,
watch: vec![],
watch_mode: crate::pitchfork_toml::WatchMode::default(),
watch_base_dir: None,
mise: None,
active_port: None,
Expand Down
1 change: 1 addition & 0 deletions src/supervisor/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ impl Supervisor {
o.depends = Some(opts.depends.clone());
o.env = opts.env.clone();
o.watch = Some(opts.watch.clone());
o.watch_mode = Some(opts.watch_mode);
o.watch_base_dir = opts.watch_base_dir.clone();
o.mise = opts.mise;
o.memory_limit = opts.memory_limit;
Expand Down
6 changes: 6 additions & 0 deletions src/supervisor/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::pitchfork_toml::CpuLimit;
use crate::pitchfork_toml::CronRetrigger;
use crate::pitchfork_toml::MemoryLimit;
use crate::pitchfork_toml::PitchforkToml;
use crate::pitchfork_toml::WatchMode;
use crate::procs::PROCS;
use crate::settings::settings;
use indexmap::IndexMap;
Expand Down Expand Up @@ -54,6 +55,7 @@ pub(crate) struct UpsertDaemonOpts {
pub depends: Option<Vec<DaemonId>>,
pub env: Option<IndexMap<String, String>>,
pub watch: Option<Vec<String>>,
pub watch_mode: Option<WatchMode>,
pub watch_base_dir: Option<PathBuf>,
pub mise: Option<bool>,
/// Memory limit for the daemon process
Expand Down Expand Up @@ -110,6 +112,7 @@ impl UpsertDaemonOpts {
depends: None,
env: None,
watch: None,
watch_mode: None,
watch_base_dir: None,
mise: None,
memory_limit: None,
Expand Down Expand Up @@ -206,6 +209,9 @@ impl Supervisor {
watch: opts
.watch
.unwrap_or_else(|| existing.map(|d| d.watch.clone()).unwrap_or_default()),
watch_mode: opts
.watch_mode
.unwrap_or_else(|| existing.map(|d| d.watch_mode).unwrap_or_default()),
watch_base_dir: opts
.watch_base_dir
.or(existing.and_then(|d| d.watch_base_dir.clone())),
Expand Down
Loading
Loading