Skip to content

Commit 1b47458

Browse files
Yuan325gemini-code-assist[bot]duwenxin99
authored andcommitted
feat: add polling system to dynamic reloading (googleapis#2466)
Support polling system to existing Toolbox dynamic reloading feature. | flag | description | default | | --- | --- | --- | | `--poll-interval` | Specifies the polling frequency (seconds) for configuration file updates. | `0` | With `--poll-interval` = 0, it will not run the polling system at all. When polling is active, Toolbox checks for configuration file updates at every specified interval (in seconds). Fixes googleapis#2346 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Wenxin Du <117315983+duwenxin99@users.noreply.github.com>
1 parent 2792b99 commit 1b47458

File tree

4 files changed

+106
-10
lines changed

4 files changed

+106
-10
lines changed

cmd/root.go

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command {
125125
// TODO: Insecure by default. Might consider updating this for v1.0.0
126126
flags.StringSliceVar(&opts.Cfg.AllowedOrigins, "allowed-origins", []string{"*"}, "Specifies a list of origins permitted to access this server. Defaults to '*'.")
127127
flags.StringSliceVar(&opts.Cfg.AllowedHosts, "allowed-hosts", []string{"*"}, "Specifies a list of hosts permitted to access this server. Defaults to '*'.")
128+
flags.IntVar(&opts.Cfg.PollInterval, "poll-interval", 0, "Specifies the polling frequency (seconds) for configuration file updates.")
128129

129130
// wrap RunE command so that we have access to original Command object
130131
cmd.RunE = func(*cobra.Command, []string) error { return run(cmd, opts) }
@@ -195,16 +196,58 @@ func validateReloadEdits(
195196
return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, nil
196197
}
197198

199+
// Helper to check if a file has a newer ModTime than stored in the map
200+
func checkModTime(path string, mTime time.Time, lastSeen map[string]time.Time) bool {
201+
if mTime.After(lastSeen[path]) {
202+
lastSeen[path] = mTime
203+
return true
204+
}
205+
return false
206+
}
207+
208+
// Helper to scan watched files and check their modification times in polling system
209+
func scanWatchedFiles(watchingFolder bool, folderToWatch string, watchedFiles map[string]bool, lastSeen map[string]time.Time) (map[string]bool, bool, error) {
210+
changed := false
211+
currentDiskFiles := make(map[string]bool)
212+
if watchingFolder {
213+
files, err := os.ReadDir(folderToWatch)
214+
if err != nil {
215+
return nil, changed, fmt.Errorf("error reading tools folder %w", err)
216+
}
217+
for _, f := range files {
218+
if !f.IsDir() && (strings.HasSuffix(f.Name(), ".yaml") || strings.HasSuffix(f.Name(), ".yml")) {
219+
fullPath := filepath.Join(folderToWatch, f.Name())
220+
currentDiskFiles[fullPath] = true
221+
if info, err := f.Info(); err == nil {
222+
if checkModTime(fullPath, info.ModTime(), lastSeen) {
223+
changed = true
224+
}
225+
}
226+
}
227+
}
228+
} else {
229+
for f := range watchedFiles {
230+
if info, err := os.Stat(f); err == nil {
231+
currentDiskFiles[f] = true
232+
if checkModTime(f, info.ModTime(), lastSeen) {
233+
changed = true
234+
}
235+
}
236+
}
237+
}
238+
return currentDiskFiles, changed, nil
239+
}
240+
198241
// watchChanges checks for changes in the provided yaml tools file(s) or folder.
199-
func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server) {
242+
func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server, pollTickerSecond int) {
200243
logger, err := util.LoggerFromContext(ctx)
201244
if err != nil {
202245
panic(err)
203246
}
204247

205248
w, err := fsnotify.NewWatcher()
206249
if err != nil {
207-
logger.WarnContext(ctx, "error setting up new watcher %s", err)
250+
logger.WarnContext(ctx, fmt.Sprintf("error setting up new watcher %s", err))
208251
return
209252
}
210253

@@ -238,6 +281,23 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
238281
logger.DebugContext(ctx, fmt.Sprintf("Added directory %s to watcher.", dir))
239282
}
240283

284+
lastSeen := make(map[string]time.Time)
285+
var pollTickerChan <-chan time.Time
286+
if pollTickerSecond > 0 {
287+
ticker := time.NewTicker(time.Duration(pollTickerSecond) * time.Second)
288+
defer ticker.Stop()
289+
pollTickerChan = ticker.C // Assign the channel
290+
logger.DebugContext(ctx, fmt.Sprintf("NFS polling enabled every %v", pollTickerSecond))
291+
292+
// Pre-populate lastSeen to avoid an initial spurious reload
293+
_, _, err = scanWatchedFiles(watchingFolder, folderToWatch, watchedFiles, lastSeen)
294+
if err != nil {
295+
logger.WarnContext(ctx, err.Error())
296+
}
297+
} else {
298+
logger.DebugContext(ctx, "NFS polling disabled (interval is 0)")
299+
}
300+
241301
// debounce timer is used to prevent multiple writes triggering multiple reloads
242302
debounceDelay := 100 * time.Millisecond
243303
debounce := time.NewTimer(1 * time.Minute)
@@ -248,13 +308,36 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
248308
case <-ctx.Done():
249309
logger.DebugContext(ctx, "file watcher context cancelled")
250310
return
311+
case <-pollTickerChan:
312+
// Get files that are currently on disk
313+
currentDiskFiles, changed, err := scanWatchedFiles(watchingFolder, folderToWatch, watchedFiles, lastSeen)
314+
if err != nil {
315+
logger.WarnContext(ctx, err.Error())
316+
continue
317+
}
318+
319+
// Check for Deletions
320+
// If it was in lastSeen but is NOT in currentDiskFiles, it's
321+
// deleted; we will need to reload the server.
322+
for path := range lastSeen {
323+
if !currentDiskFiles[path] {
324+
logger.DebugContext(ctx, fmt.Sprintf("File deleted (detected via polling): %s", path))
325+
delete(lastSeen, path)
326+
changed = true
327+
}
328+
}
329+
if changed {
330+
logger.DebugContext(ctx, "File change detected via polling")
331+
// once this timer runs out, it will trigger debounce.C
332+
debounce.Reset(debounceDelay)
333+
}
251334
case err, ok := <-w.Errors:
252335
if !ok {
253336
logger.WarnContext(ctx, "file watcher was closed unexpectedly")
254337
return
255338
}
256339
if err != nil {
257-
logger.WarnContext(ctx, "file watcher error %s", err)
340+
logger.WarnContext(ctx, fmt.Sprintf("file watcher error %s", err))
258341
return
259342
}
260343

@@ -289,14 +372,14 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
289372
logger.DebugContext(ctx, "Reloading tools folder.")
290373
reloadedToolsFile, err = internal.LoadAndMergeToolsFolder(ctx, folderToWatch)
291374
if err != nil {
292-
logger.WarnContext(ctx, "error loading tools folder %s", err)
375+
logger.WarnContext(ctx, fmt.Sprintf("error loading tools folder %s", err))
293376
continue
294377
}
295378
} else {
296379
logger.DebugContext(ctx, "Reloading tools file(s).")
297380
reloadedToolsFile, err = internal.LoadAndMergeToolsFiles(ctx, slices.Collect(maps.Keys(watchedFiles)))
298381
if err != nil {
299-
logger.WarnContext(ctx, "error loading tools files %s", err)
382+
logger.WarnContext(ctx, fmt.Sprintf("error loading tools files %s", err))
300383
continue
301384
}
302385
}
@@ -417,7 +500,7 @@ func run(cmd *cobra.Command, opts *internal.ToolboxOptions) error {
417500
if isCustomConfigured && !opts.Cfg.DisableReload {
418501
watchDirs, watchedFiles := resolveWatcherInputs(opts.ToolsFile, opts.ToolsFiles, opts.ToolsFolder)
419502
// start watching the file(s) or folder for changes to trigger dynamic reloading
420-
go watchChanges(ctx, watchDirs, watchedFiles, s)
503+
go watchChanges(ctx, watchDirs, watchedFiles, s, opts.Cfg.PollInterval)
421504
}
422505

423506
// wait for either the server to error out or the command's context to be canceled

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ func TestSingleEdit(t *testing.T) {
590590
watchedFiles := map[string]bool{cleanFileToWatch: true}
591591
watchDirs := map[string]bool{watchDir: true}
592592

593-
go watchChanges(ctx, watchDirs, watchedFiles, mockServer)
593+
go watchChanges(ctx, watchDirs, watchedFiles, mockServer, 0)
594594

595595
// escape backslash so regex doesn't fail on windows filepaths
596596
regexEscapedPathFile := strings.ReplaceAll(cleanFileToWatch, `\`, `\\\\*\\`)

docs/en/reference/cli.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ description: >
1616
| | `--log-level` | Specify the minimum level logged. Allowed: 'DEBUG', 'INFO', 'WARN', 'ERROR'. | `info` |
1717
| | `--logging-format` | Specify logging format to use. Allowed: 'standard' or 'JSON'. | `standard` |
1818
| `-p` | `--port` | Port the server will listen on. | `5000` |
19-
| | `--prebuilt` | Use one or more prebuilt tool configuration by source type. See [Prebuilt Tools Reference](prebuilt-tools.md) for allowed values. | |
19+
| | `--prebuilt` | Use one or more prebuilt tool configuration by source type. See [Prebuilt Tools Reference](prebuilt-tools.md) for allowed values. | |
2020
| | `--stdio` | Listens via MCP STDIO instead of acting as a remote HTTP server. | |
2121
| | `--telemetry-gcp` | Enable exporting directly to Google Cloud Monitoring. | |
2222
| | `--telemetry-otlp` | Enable exporting using OpenTelemetry Protocol (OTLP) to the specified endpoint (e.g. 'http://127.0.0.1:4318') | |
@@ -28,6 +28,7 @@ description: >
2828
| | `--allowed-origins` | Specifies a list of origins permitted to access this server for CORs access. | `*` |
2929
| | `--allowed-hosts` | Specifies a list of hosts permitted to access this server to prevent DNS rebinding attacks. | `*` |
3030
| | `--user-agent-metadata` | Appends additional metadata to the User-Agent. | |
31+
| | `--poll-interval` | Specifies the polling frequency (seconds) for configuration file updates. | `0` |
3132
| `-v` | `--version` | version for toolbox | |
3233

3334
## Sub Commands
@@ -133,8 +134,18 @@ used at a time.
133134

134135
### Hot Reload
135136

136-
Toolbox enables dynamic reloading by default. To disable, use the
137-
`--disable-reload` flag.
137+
Toolbox supports two methods for detecting configuration changes: **Push**
138+
(event-driven) and **Poll** (interval-based). To completely disable all hot
139+
reloading, use the `--disable-reload` flag.
140+
141+
* **Push (Default):** Toolbox uses a highly efficient push system that listens
142+
for instant OS-level file events to reload configurations the moment you save.
143+
* **Poll (Fallback):** Alternatively, you can use the
144+
`--poll-interval=<seconds>` flag to actively check for updates at a set
145+
cadence. Unlike the push system, polling "pulls" the file status manually,
146+
which is a great fallback for network drives or container volumes where OS
147+
events might get dropped. Set the interval to `0` to disable the polling
148+
system.
138149

139150
### Toolbox UI
140151

internal/server/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ type ServerConfig struct {
7575
AllowedHosts []string
7676
// UserAgentMetadata specifies additional metadata to append to the User-Agent string.
7777
UserAgentMetadata []string
78+
// PollInterval sets the polling frequency for configuration file updates.
79+
PollInterval int
7880
}
7981

8082
type logFormat string

0 commit comments

Comments
 (0)