Skip to content

Commit 3dce8f3

Browse files
committed
fix: Allow tools running in the terminal to prevent the workspace from stopping due to idling
This adds support for a CLI Watcher in che-machine-exec to prevent DevWorkspace idling when long-running CLI tools are running in the terminal. The watcher is user-configurable via a '.noidle' YAML file. No admin privileges are required. How configuration is resolved: The '.noidle' file is located by searching in the following order: - A path specified by the CLI_WATCHER_CONFIG environment variable - Searching upward from the current project directory toward $PROJECTS_ROOT, looking for .noidle - Falling back to '$HOME/.noidle' (e.g. '~/.noidle') - If not found, CLI Watcher waits and checks again on the next poll Example .noidle configuration: ```yaml enabled: true watchedCommands: - helm - odo - sleep checkPeriodSeconds: 60 ``` Where: - enabled: Enables or disables the CLI watcher (boolean) - watchedCommands: List of command names to monitor - checkPeriodSeconds: Polling interval in seconds (default is 60) Benefits: - Works entirely in user space — no container or cluster admin config needed - Supports live updates (file can be added, edited, or removed while running) - Helps avoid idle timeout disconnects during long-running CLI workflows - Allows workspace authors to tailor idle behavior to specific tools Issue: eclipse-che/che#23529 Signed-off-by: Victor Rubezhny <[email protected]>
1 parent 8e9c4c1 commit 3dce8f3

File tree

3 files changed

+323
-2
lines changed

3 files changed

+323
-2
lines changed

build/dockerfiles/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ RUN apk --update --no-cache add \
4141
WORKDIR /che-machine-exec/
4242
COPY . .
4343
# to test FIPS compliance, run https://github.com/openshift/check-payload#scan-a-container-or-operator-image against a built image
44-
ENV CGO_ENABLED=1
44+
ENV CGO_ENABLED=0
4545
RUN GOOS=linux go build -mod=vendor -a -ldflags '-w -s' -a -installsuffix cgo -o /go/bin/che-machine-exec .
4646

4747
# NOTE: could not compile with node:18-alpine, so for now stick with node:16-alpine

timeout/cli-watcher.go

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

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)