Skip to content

Commit 6efe1d3

Browse files
authored
Merge pull request #332 from vrubezhny/fix-add-cli-watcher-to-track-activity
fix: Allow tools running in the terminal to prevent the workspace from stopping due to idling
2 parents 0603088 + 57ae8b2 commit 6efe1d3

File tree

2 files changed

+356
-1
lines changed

2 files changed

+356
-1
lines changed

timeout/cli-watcher.go

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
//
2+
// Copyright (c) 2025 Red Hat, Inc.
3+
// This program and the accompanying materials are made
4+
// available under the terms of the Eclipse Public License 2.0
5+
// which is available at https://www.eclipse.org/legal/epl-2.0/
6+
//
7+
// SPDX-License-Identifier: EPL-2.0
8+
//
9+
// Contributors:
10+
// Red Hat, Inc. - initial API and implementation
11+
//
12+
13+
package timeout
14+
15+
import (
16+
"fmt"
17+
"os"
18+
"path/filepath"
19+
"slices"
20+
"strings"
21+
"time"
22+
23+
"github.com/sirupsen/logrus"
24+
"gopkg.in/yaml.v2"
25+
)
26+
27+
type cliWatcherConfig struct {
28+
WatchedCommands []string `yaml:"watchedCommands"`
29+
IgnoredCommands []string `json:"-"`
30+
CheckPeriodSeconds int `yaml:"checkPeriodSeconds"`
31+
Enabled bool `yaml:"enabled"`
32+
_lastModTime time.Time `json:"-"`
33+
}
34+
35+
// Watcher monitors CLI processes and invokes a tick callback when active ones are found
36+
type cliWatcher struct {
37+
config *cliWatcherConfig
38+
warnedMissingConfig bool
39+
stopChan chan struct{}
40+
started bool
41+
tickFunc func()
42+
}
43+
44+
// CLIs that should never prevent idling
45+
var excludedCommands = []string{"tail"}
46+
47+
// New creates a new Watcher with the given config and tick callback
48+
func NewCliWatcher(tickFunc func()) *cliWatcher {
49+
return &cliWatcher{
50+
stopChan: make(chan struct{}),
51+
tickFunc: tickFunc,
52+
}
53+
}
54+
55+
// Start begins the watcher loop
56+
func (w *cliWatcher) Start() {
57+
if w.started {
58+
return
59+
}
60+
w.started = true
61+
62+
go func() {
63+
var err error
64+
w.config, err = w.loadConfig(getConfigPath(), w.config)
65+
if err != nil {
66+
logrus.Errorf("CLI Watcher: Failed to reload config: %v", err)
67+
}
68+
69+
chkPeriod := 60
70+
if w.config != nil {
71+
chkPeriod = w.config.CheckPeriodSeconds
72+
}
73+
74+
ticker := time.NewTicker(time.Duration(chkPeriod) * time.Second)
75+
defer ticker.Stop()
76+
77+
for {
78+
select {
79+
case <-w.stopChan:
80+
logrus.Infof("CLI Watcher: Stopped")
81+
return
82+
83+
case <-ticker.C:
84+
oldPeriod := chkPeriod
85+
86+
// Reload config
87+
w.config, err = w.loadConfig(getConfigPath(), w.config)
88+
if err != nil {
89+
logrus.Errorf("CLI Watcher: Failed to reload config: %v", err)
90+
}
91+
92+
if w.config == nil || !w.config.Enabled {
93+
if chkPeriod != 60 {
94+
logrus.Infof("CLI Watcher: Config was removed or disabled — resetting check period to default (60s)")
95+
chkPeriod = 60
96+
ticker.Stop()
97+
ticker = time.NewTicker(time.Duration(chkPeriod) * time.Second)
98+
}
99+
continue
100+
}
101+
102+
if w.config.CheckPeriodSeconds > 0 && w.config.CheckPeriodSeconds != oldPeriod {
103+
logrus.Infof("CLI Watcher: Detected new check period: %d seconds (was %d), restarting ticker", w.config.CheckPeriodSeconds, oldPeriod)
104+
chkPeriod = w.config.CheckPeriodSeconds
105+
ticker.Stop()
106+
ticker = time.NewTicker(time.Duration(chkPeriod) * time.Second)
107+
}
108+
109+
found, name := isWatchedProcessRunning(w.config.WatchedCommands)
110+
if found {
111+
logrus.Infof("CLI Watcher: Detected CLI command: %s — reporting activity tick", name)
112+
if w.tickFunc != nil {
113+
w.tickFunc()
114+
}
115+
}
116+
}
117+
}
118+
}()
119+
120+
logrus.Infof("CLI Watcher: Started")
121+
}
122+
123+
// Stop terminates the watcher loop
124+
func (w *cliWatcher) Stop() {
125+
if !w.started {
126+
return
127+
}
128+
close(w.stopChan)
129+
w.started = false
130+
}
131+
132+
// Scans /proc to check if any watched process is running
133+
func isWatchedProcessRunning(watched []string) (bool, string) {
134+
procEntries, err := os.ReadDir("/proc")
135+
if err != nil {
136+
logrus.Warnf("CLI Watcher: Cannot read /proc: %v", err)
137+
return false, ""
138+
}
139+
140+
for _, entry := range procEntries {
141+
if !entry.IsDir() || !isNumeric(entry.Name()) {
142+
continue
143+
}
144+
145+
pid := entry.Name()
146+
if pid == "1" { // Skip PID 1 (main container process)
147+
continue
148+
}
149+
150+
cmdlinePath := filepath.Join("/proc", pid, "cmdline")
151+
data, err := os.ReadFile(cmdlinePath)
152+
if err != nil || len(data) == 0 {
153+
continue
154+
}
155+
156+
cmdParts := strings.Split(string(data), "\x00")
157+
if len(cmdParts) == 0 {
158+
continue
159+
}
160+
161+
// Match against all command line parts, not just the first
162+
for _, part := range cmdParts {
163+
partName := filepath.Base(part)
164+
for _, keyword := range watched {
165+
if partName == keyword {
166+
return true, keyword
167+
}
168+
}
169+
}
170+
}
171+
172+
return false, ""
173+
}
174+
175+
func isNumeric(s string) bool {
176+
for _, c := range s {
177+
if c < '0' || c > '9' {
178+
return false
179+
}
180+
}
181+
return true
182+
}
183+
184+
// Finds the CLI Watcher configuration file in:
185+
// 1. Use explicit override by using "CLI_WATCHER_CONFIG" env. variable, or if not set then
186+
// 2. Search for '.noidle' upward from current project directory up to "PROJECTS_ROOT" directory, or
187+
// 3. Fallback to $HOME/.<binary> file, or if doesn't exist/isn't accessble then
188+
// 4. Otherwise, give up. Repeating the search on next run (thus waiting for a config to appear)
189+
func getConfigPath() string {
190+
191+
// 1. Use explicit override
192+
if configEnv := os.Getenv("CLI_WATCHER_CONFIG"); configEnv != "" {
193+
return configEnv
194+
}
195+
196+
const configFileName = ".noidle"
197+
198+
// 2. Search upward from current project directory
199+
root := os.Getenv("PROJECTS_ROOT")
200+
if root == "" {
201+
root = "/"
202+
}
203+
204+
start := os.Getenv("PROJECT_SOURCE")
205+
if start == "" {
206+
start = os.Getenv("PROJECTS_ROOT")
207+
}
208+
209+
if start == "" {
210+
start, _ = os.Getwd()
211+
}
212+
213+
if path := findUpward(start, root, configFileName); path != "" {
214+
return path
215+
}
216+
217+
// 3. Fallback to $HOME/.<binary>
218+
if home := os.Getenv("HOME"); home != "" && home != "/" {
219+
homeCfg := filepath.Join(home, configFileName)
220+
if _, err := os.Stat(homeCfg); err == nil {
221+
return homeCfg
222+
}
223+
}
224+
225+
// 4. Give up
226+
return ""
227+
}
228+
229+
func findUpward(start, stop, filename string) string {
230+
current := start
231+
for {
232+
candidate := filepath.Join(current, filename)
233+
if _, err := os.Stat(candidate); err == nil {
234+
return candidate
235+
}
236+
237+
if current == stop || current == "/" {
238+
break
239+
}
240+
241+
parent := filepath.Dir(current)
242+
if parent == current { // root reached
243+
break
244+
}
245+
current = parent
246+
}
247+
return ""
248+
}
249+
250+
// Loads `.noidle` configuration file (or the one that is specified in ” environment variable) into the CLI Watcher configuration struct.
251+
// Example configuraiton file:
252+
// ```yaml
253+
//
254+
// enabled: true
255+
// checkPeriodSeconds: 30
256+
// watchedCommands:
257+
// - helm
258+
// - odo
259+
// - sleep
260+
//
261+
// ````
262+
func (w *cliWatcher) loadConfig(path string, current *cliWatcherConfig) (*cliWatcherConfig, error) {
263+
info, err := os.Stat(path)
264+
if os.IsNotExist(err) {
265+
if current != nil {
266+
logrus.Infof("CLI Watcher: Config file at %s was removed, stopping config-based detection", path)
267+
} else if !w.warnedMissingConfig {
268+
if strings.TrimSpace(path) == "" {
269+
logrus.Infof("CLI Watcher: Config file not found, waiting for it to appear...")
270+
} else {
271+
logrus.Infof("CLI Watcher: Config file not found at %s, waiting for it to appear...", path)
272+
}
273+
w.warnedMissingConfig = true
274+
}
275+
return nil, nil
276+
} else if err != nil {
277+
return current, fmt.Errorf("CLI Watcher: Failed to stat config file: %w", err)
278+
}
279+
280+
if w.warnedMissingConfig {
281+
logrus.Infof("CLI Watcher: Config file appeared at %s", path)
282+
w.warnedMissingConfig = false
283+
}
284+
285+
if current != nil && !info.ModTime().After(current._lastModTime) {
286+
return current, nil // no change
287+
}
288+
289+
data, err := os.ReadFile(path)
290+
if err != nil {
291+
return current, fmt.Errorf("CLI Watcher: Failed to read config file: %w", err)
292+
}
293+
294+
var newCfg cliWatcherConfig
295+
if err := yaml.Unmarshal(data, &newCfg); err != nil {
296+
return current, fmt.Errorf("CLI Watcher: Failed to parse config file: %w", err)
297+
}
298+
299+
newCfg._lastModTime = info.ModTime()
300+
newCfg = applyDefaults(newCfg)
301+
newCfg = ignoreExclusions(excludedCommands, newCfg)
302+
303+
logrus.Infof("CLI Watcher: Config reloaded from %s", path)
304+
if newCfg.Enabled {
305+
logrus.Infof("CLI Watcher: Detecting active commands: %v...", newCfg.WatchedCommands)
306+
if len(newCfg.IgnoredCommands) > 0 {
307+
logrus.Infof("CLI Watcher: Skipping watch for: %v...", newCfg.IgnoredCommands)
308+
}
309+
logrus.Infof("CLI Watcher: Detection period is %d seconds", newCfg.CheckPeriodSeconds)
310+
} else {
311+
logrus.Infof("CLI Watcher: Disabled by configuration. CLI idling prevention is turned off.")
312+
}
313+
314+
return &newCfg, nil
315+
}
316+
317+
// Remove excluded CLIs from the watcher configuration.
318+
func ignoreExclusions(exclusions []string, cfg cliWatcherConfig) cliWatcherConfig {
319+
var filtered, ignored []string
320+
321+
for _, cmd := range cfg.WatchedCommands {
322+
name := strings.ToLower(strings.TrimSpace(cmd))
323+
if slices.ContainsFunc(exclusions, func(ex string) bool {
324+
return strings.EqualFold(strings.TrimSpace(ex), name)
325+
}) {
326+
ignored = append(ignored, cmd)
327+
continue
328+
}
329+
filtered = append(filtered, cmd)
330+
}
331+
332+
cfg.WatchedCommands = filtered
333+
cfg.IgnoredCommands = ignored
334+
return cfg
335+
}
336+
337+
// applyDefaults sets fallback values
338+
func applyDefaults(c cliWatcherConfig) cliWatcherConfig {
339+
if c.CheckPeriodSeconds <= 0 {
340+
c.CheckPeriodSeconds = 60
341+
}
342+
return c
343+
}

timeout/inactivity.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright (c) 2019-2022 Red Hat, Inc.
2+
// Copyright (c) 2019-2025 Red Hat, Inc.
33
// This program and the accompanying materials are made
44
// available under the terms of the Eclipse Public License 2.0
55
// which is available at https://www.eclipse.org/legal/epl-2.0/
@@ -60,6 +60,7 @@ func NewInactivityIdleManager(idleTimeout, stopRetryPeriod time.Duration) (Inact
6060
idleTimeout: idleTimeout,
6161
stopRetryPeriod: stopRetryPeriod,
6262
activityC: make(chan bool),
63+
watcher: nil, // Will be initialized in Start()
6364
}, nil
6465
}
6566

@@ -78,6 +79,8 @@ type inactivityIdleManagerImpl struct {
7879
stopRetryPeriod time.Duration
7980

8081
activityC chan bool
82+
83+
watcher *cliWatcher
8184
}
8285

8386
func (m inactivityIdleManagerImpl) Tick() {
@@ -118,4 +121,13 @@ func (m inactivityIdleManagerImpl) Start() {
118121
}
119122
}
120123
}()
124+
125+
m.watcher = NewCliWatcher(m.Tick)
126+
m.watcher.Start()
127+
}
128+
129+
func (m *inactivityIdleManagerImpl) Stop() {
130+
if m.watcher != nil {
131+
m.watcher.Stop()
132+
}
121133
}

0 commit comments

Comments
 (0)