feat: add polling system to dynamic reloading

This commit is contained in:
Yuan Teoh
2026-02-12 15:31:00 -08:00
parent f032389a07
commit c1f19d3d15
4 changed files with 73 additions and 3 deletions

View File

@@ -401,6 +401,7 @@ func NewCommand(opts ...Option) *Command {
// TODO: Insecure by default. Might consider updating this for v1.0.0
flags.StringSliceVar(&cmd.cfg.AllowedOrigins, "allowed-origins", []string{"*"}, "Specifies a list of origins permitted to access this server. Defaults to '*'.")
flags.StringSliceVar(&cmd.cfg.AllowedHosts, "allowed-hosts", []string{"*"}, "Specifies a list of hosts permitted to access this server. Defaults to '*'.")
flags.IntVar(&cmd.cfg.PollInterval, "poll-interval", 0, "Specifies the polling frequency (seconds) for configuration file updates.")
persistentFlags.StringSliceVar(&cmd.cfg.UserAgentMetadata, "user-agent-metadata", []string{}, "Appends additional metadata to the User-Agent.")
// wrap RunE command so that we have access to original Command object
@@ -791,8 +792,22 @@ func validateReloadEdits(
return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, nil
}
// Helper to check if a file has a newer ModTime than stored in the map
func checkModTime(path string, lastSeen map[string]time.Time) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
mTime := info.ModTime()
if mTime.After(lastSeen[path]) {
lastSeen[path] = mTime
return true
}
return false
}
// watchChanges checks for changes in the provided yaml tools file(s) or folder.
func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server) {
func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server, pollTickerSecond int) {
logger, err := util.LoggerFromContext(ctx)
if err != nil {
panic(err)
@@ -806,6 +821,17 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
defer w.Close()
var pollTickerChan <-chan time.Time
if pollTickerSecond > 0 {
ticker := time.NewTicker(time.Duration(pollTickerSecond) * time.Second)
defer ticker.Stop()
pollTickerChan = ticker.C // Assign the channel
logger.DebugContext(ctx, fmt.Sprintf("NFS polling enabled every %v", pollTickerSecond))
} else {
logger.DebugContext(ctx, "NFS polling disabled (interval is 0)")
}
lastSeen := make(map[string]time.Time)
watchingFolder := false
var folderToWatch string
@@ -844,6 +870,37 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
case <-ctx.Done():
logger.DebugContext(ctx, "file watcher context cancelled")
return
case <-pollTickerChan:
changed := false
if watchingFolder {
// Scan directory for any .yaml/.yml file changes
files, err := os.ReadDir(folderToWatch)
if err != nil {
logger.WarnContext(ctx, "error reading tools folder %s", err)
continue
}
for _, f := range files {
if !f.IsDir() && (strings.HasSuffix(f.Name(), ".yaml") || strings.HasSuffix(f.Name(), ".yml")) {
fullPath := filepath.Join(folderToWatch, f.Name())
if checkModTime(fullPath, lastSeen) {
changed = true
}
}
}
} else {
files := slices.Collect(maps.Keys(watchedFiles))
// Check specific files in watchedFiles map
for _, f := range files {
if checkModTime(f, lastSeen) {
changed = true
}
}
}
if changed {
logger.DebugContext(ctx, "NFS remote change detected via polling")
// once this timer runs out, it will trigger debounce.C
debounce.Reset(debounceDelay)
}
case err, ok := <-w.Errors:
if !ok {
logger.WarnContext(ctx, "file watcher was closed unexpectedly")
@@ -1192,7 +1249,7 @@ func run(cmd *Command) error {
if isCustomConfigured && !cmd.cfg.DisableReload {
watchDirs, watchedFiles := resolveWatcherInputs(cmd.tools_file, cmd.tools_files, cmd.tools_folder)
// start watching the file(s) or folder for changes to trigger dynamic reloading
go watchChanges(ctx, watchDirs, watchedFiles, s)
go watchChanges(ctx, watchDirs, watchedFiles, s, cmd.cfg.PollInterval)
}
// wait for either the server to error out or the command's context to be canceled

View File

@@ -2024,7 +2024,7 @@ func TestSingleEdit(t *testing.T) {
watchedFiles := map[string]bool{cleanFileToWatch: true}
watchDirs := map[string]bool{watchDir: true}
go watchChanges(ctx, watchDirs, watchedFiles, mockServer)
go watchChanges(ctx, watchDirs, watchedFiles, mockServer, 0)
// escape backslash so regex doesn't fail on windows filepaths
regexEscapedPathFile := strings.ReplaceAll(cleanFileToWatch, `\`, `\\\\*\\`)

View File

@@ -28,6 +28,7 @@ description: >
| | `--allowed-origins` | Specifies a list of origins permitted to access this server for CORs access. | `*` |
| | `--allowed-hosts` | Specifies a list of hosts permitted to access this server to prevent DNS rebinding attacks. | `*` |
| | `--user-agent-metadata` | Appends additional metadata to the User-Agent. | |
| | `--poll-interval` | Specifies the polling frequency (seconds) for configuration file updates. | `0` |
| `-v` | `--version` | version for toolbox | |
## Sub Commands
@@ -136,6 +137,16 @@ used at a time.
Toolbox enables dynamic reloading by default. To disable, use the
`--disable-reload` flag.
Use the `--polling-interval` flag to manually detect configuration file updates.
When the polling interval is `0`, the polling system is disabled.
{{< notice tip >}}
FOr polling to be effective when running Kubernetes, PersistentVolume or
StorageClass must be set to refresh attributes rapidly by setting `actimeo=1`.
Actimeo setting determines the duration for which a client trusts its local
cache for file attributes.
{{< /notice >}}
### Toolbox UI
To launch Toolbox's interactive UI, use the `--ui` flag. This allows you to test

View File

@@ -75,6 +75,8 @@ type ServerConfig struct {
AllowedHosts []string
// UserAgentMetadata specifies additional metadata to append to the User-Agent string.
UserAgentMetadata []string
// PollInterval sets the polling frequency for configuration file updates.
PollInterval int
}
type logFormat string