feat: implement async timeout for segments with cache support

Co-authored-by: JanDeDobbeleer <2492783+JanDeDobbeleer@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-07-18 07:26:56 +00:00
co-authored by JanDeDobbeleer
parent 6638dfc2df
commit 46eee055cb
5 changed files with 457 additions and 3 deletions
+80
View File
@@ -0,0 +1,80 @@
package cache
import (
"encoding/json"
"fmt"
"time"
)
// AsyncSegmentData represents cached data for async segments
type AsyncSegmentData struct {
Text string `json:"text"`
Enabled bool `json:"enabled"`
Timestamp time.Time `json:"timestamp"`
Duration Duration `json:"duration"`
}
// AsyncSegmentCache manages async segment caching
type AsyncSegmentCache struct {
cache Cache
}
// NewAsyncSegmentCache creates a new async segment cache
func NewAsyncSegmentCache(cache Cache) *AsyncSegmentCache {
return &AsyncSegmentCache{
cache: cache,
}
}
// GetSegmentData retrieves cached segment data
func (a *AsyncSegmentCache) GetSegmentData(segmentName, cacheKey string) (*AsyncSegmentData, bool) {
key := fmt.Sprintf("async_segment_%s_%s", segmentName, cacheKey)
data, found := a.cache.Get(key)
if !found {
return nil, false
}
var segmentData AsyncSegmentData
if err := json.Unmarshal([]byte(data), &segmentData); err != nil {
return nil, false
}
return &segmentData, true
}
// SetSegmentData stores segment data in cache
func (a *AsyncSegmentCache) SetSegmentData(segmentName, cacheKey string, data *AsyncSegmentData) {
key := fmt.Sprintf("async_segment_%s_%s", segmentName, cacheKey)
jsonData, err := json.Marshal(data)
if err != nil {
return
}
a.cache.Set(key, string(jsonData), data.Duration)
}
// DeleteSegmentData removes cached segment data
func (a *AsyncSegmentCache) DeleteSegmentData(segmentName, cacheKey string) {
key := fmt.Sprintf("async_segment_%s_%s", segmentName, cacheKey)
a.cache.Delete(key)
}
// IsAsyncProcessRunning checks if an async process is currently running for a segment
func (a *AsyncSegmentCache) IsAsyncProcessRunning(segmentName, cacheKey string) bool {
key := fmt.Sprintf("async_process_%s_%s", segmentName, cacheKey)
_, found := a.cache.Get(key)
return found
}
// SetAsyncProcessRunning marks an async process as running
func (a *AsyncSegmentCache) SetAsyncProcessRunning(segmentName, cacheKey string) {
key := fmt.Sprintf("async_process_%s_%s", segmentName, cacheKey)
// Set a short TTL to prevent stale process markers
a.cache.Set(key, "running", Duration("5m"))
}
// ClearAsyncProcessRunning removes the async process marker
func (a *AsyncSegmentCache) ClearAsyncProcessRunning(segmentName, cacheKey string) {
key := fmt.Sprintf("async_process_%s_%s", segmentName, cacheKey)
a.cache.Delete(key)
}
+78 -2
View File
@@ -2,16 +2,21 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"time"
"github.com/jandedobbeleer/oh-my-posh/src/cache" "github.com/jandedobbeleer/oh-my-posh/src/cache"
"github.com/jandedobbeleer/oh-my-posh/src/config"
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
"github.com/jandedobbeleer/oh-my-posh/src/shell"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// getCmd represents the get command // getCmd represents the get command
var getCache = &cobra.Command{ var getCache = &cobra.Command{
Use: "cache [path|clear|edit]", Use: "cache [path|clear|edit|refresh-segment]",
Short: "Interact with the oh-my-posh cache", Short: "Interact with the oh-my-posh cache",
Long: `Interact with the oh-my-posh cache. Long: `Interact with the oh-my-posh cache.
@@ -19,11 +24,13 @@ You can do the following:
- path: list cache path - path: list cache path
- clear: remove all cache values - clear: remove all cache values
- edit: edit cache values`, - edit: edit cache values
- refresh-segment: refresh a specific segment cache`,
ValidArgs: []string{ ValidArgs: []string{
"path", "path",
"clear", "clear",
"edit", "edit",
"refresh-segment",
}, },
Args: NoArgsOrOneValidArg, Args: NoArgsOrOneValidArg,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -48,10 +55,79 @@ You can do the following:
case "edit": case "edit":
cacheFilePath := filepath.Join(cache.Path(), cache.FileName) cacheFilePath := filepath.Join(cache.Path(), cache.FileName)
exitcode = editFileWithEditor(cacheFilePath) exitcode = editFileWithEditor(cacheFilePath)
case "refresh-segment":
refreshSegmentCache(cmd)
} }
}, },
} }
// refreshSegmentCache refreshes the cache for a specific segment
func refreshSegmentCache(cmd *cobra.Command) {
segmentName, _ := cmd.Flags().GetString("segment")
cacheKey, _ := cmd.Flags().GetString("cache-key")
workingDir, _ := cmd.Flags().GetString("working-dir")
if segmentName == "" || cacheKey == "" || workingDir == "" {
fmt.Println("Error: segment, cache-key, and working-dir are required")
return
}
// Change to the working directory
if err := os.Chdir(workingDir); err != nil {
fmt.Printf("Error changing directory: %v\n", err)
return
}
// Create runtime environment
flags := &runtime.Flags{
SaveCache: true,
}
env := &runtime.Terminal{}
env.Init(flags)
defer env.Close()
// Load configuration
cfg, _ := config.Load(configFlag, shell.GENERIC, false)
// Find the segment configuration
var segment *config.Segment
for _, block := range cfg.Blocks {
for _, s := range block.Segments {
if s.Name() == segmentName || (s.Alias != "" && s.Alias == segmentName) {
segment = s
break
}
}
if segment != nil {
break
}
}
if segment == nil {
fmt.Printf("Error: segment %s not found in configuration\n", segmentName)
return
}
// Execute the segment to get fresh data
segment.Execute(env)
// Cache the result
if segment.Enabled {
asyncCache := cache.NewAsyncSegmentCache(env.Cache())
asyncData := &cache.AsyncSegmentData{
Text: segment.Text(),
Enabled: segment.Enabled,
Timestamp: time.Now(),
Duration: cache.Duration("5m"), // Default 5 minutes
}
asyncCache.SetSegmentData(segmentName, cacheKey, asyncData)
fmt.Printf("Async cache refreshed for segment: %s\n", segmentName)
}
}
func init() { func init() {
getCache.Flags().StringP("segment", "s", "", "segment name for refresh-segment command")
getCache.Flags().StringP("cache-key", "k", "", "cache key for refresh-segment command")
getCache.Flags().StringP("working-dir", "w", "", "working directory for refresh-segment command")
RootCmd.AddCommand(getCache) RootCmd.AddCommand(getCache)
} }
+48
View File
@@ -0,0 +1,48 @@
package config
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAsyncTimeoutFromConfig(t *testing.T) {
jsonConfig := `{
"version": 3,
"blocks": [
{
"type": "prompt",
"segments": [
{
"type": "git",
"style": "plain",
"async_timeout": 100,
"properties": {
"fetch_status": true
}
}
]
}
]
}`
// Create a temporary file for the config
tmpFile := "/tmp/test_config.json"
err := os.WriteFile(tmpFile, []byte(jsonConfig), 0644)
assert.NoError(t, err)
defer os.Remove(tmpFile)
// Load the config
cfg, _ := Load(tmpFile, "generic", false)
assert.NotNil(t, cfg)
// Verify the async timeout is loaded correctly
assert.Len(t, cfg.Blocks, 1)
assert.Len(t, cfg.Blocks[0].Segments, 1)
gitSegment := cfg.Blocks[0].Segments[0]
assert.Equal(t, "git", string(gitSegment.Type))
assert.Equal(t, 100*time.Nanosecond, gitSegment.AsyncTimeout)
}
+75
View File
@@ -0,0 +1,75 @@
package config
import (
"testing"
"time"
"github.com/jandedobbeleer/oh-my-posh/src/cache"
"github.com/jandedobbeleer/oh-my-posh/src/runtime/mock"
"github.com/stretchr/testify/assert"
)
func TestAsyncTimeoutConfiguration(t *testing.T) {
segment := &Segment{
Type: "git",
AsyncTimeout: 100 * time.Millisecond,
}
assert.Equal(t, 100*time.Millisecond, segment.AsyncTimeout)
assert.Equal(t, "git", string(segment.Type))
}
func TestAsyncCacheKey(t *testing.T) {
env := &mock.Environment{}
env.On("Pwd").Return("/home/user/test")
env.On("Getenv", "GIT_DIR").Return("")
segment := &Segment{
Type: "git",
env: env,
}
key := segment.generateAsyncCacheKey()
expected := "git_/home/user/test"
assert.Equal(t, expected, key)
}
func TestAsyncCache(t *testing.T) {
cacheFile := &cache.File{}
cacheFile.Init("/tmp/test_cache", false)
defer cacheFile.Close()
asyncCache := cache.NewAsyncSegmentCache(cacheFile)
// Test setting and getting async data
data := &cache.AsyncSegmentData{
Text: "test output",
Enabled: true,
Timestamp: time.Now(),
Duration: cache.Duration("5m"),
}
asyncCache.SetSegmentData("git", "test_key", data)
retrieved, found := asyncCache.GetSegmentData("git", "test_key")
assert.True(t, found)
assert.Equal(t, "test output", retrieved.Text)
assert.True(t, retrieved.Enabled)
}
func TestAsyncProcessMarker(t *testing.T) {
cacheFile := &cache.File{}
cacheFile.Init("/tmp/test_cache", false)
defer cacheFile.Close()
asyncCache := cache.NewAsyncSegmentCache(cacheFile)
// Test process marker
assert.False(t, asyncCache.IsAsyncProcessRunning("git", "test_key"))
asyncCache.SetAsyncProcessRunning("git", "test_key")
assert.True(t, asyncCache.IsAsyncProcessRunning("git", "test_key"))
asyncCache.ClearAsyncProcessRunning("git", "test_key")
assert.False(t, asyncCache.IsAsyncProcessRunning("git", "test_key"))
}
+176 -1
View File
@@ -3,6 +3,8 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -67,6 +69,7 @@ type Segment struct {
MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"` MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"`
MaxWidth int `json:"max_width,omitempty" toml:"max_width,omitempty" yaml:"max_width,omitempty"` MaxWidth int `json:"max_width,omitempty" toml:"max_width,omitempty" yaml:"max_width,omitempty"`
Timeout time.Duration `json:"timeout,omitempty" toml:"timeout,omitempty" yaml:"timeout,omitempty"` Timeout time.Duration `json:"timeout,omitempty" toml:"timeout,omitempty" yaml:"timeout,omitempty"`
AsyncTimeout time.Duration `json:"async_timeout,omitempty" toml:"async_timeout,omitempty" yaml:"async_timeout,omitempty"`
Duration time.Duration `json:"-" toml:"-" yaml:"-"` Duration time.Duration `json:"-" toml:"-" yaml:"-"`
NameLength int `json:"-" toml:"-" yaml:"-"` NameLength int `json:"-" toml:"-" yaml:"-"`
Interactive bool `json:"interactive,omitempty" toml:"interactive,omitempty" yaml:"interactive,omitempty"` Interactive bool `json:"interactive,omitempty" toml:"interactive,omitempty" yaml:"interactive,omitempty"`
@@ -124,8 +127,10 @@ func (segment *Segment) Execute(env runtime.Environment) {
return return
} }
if segment.Timeout == 0 { if segment.Timeout == 0 && segment.AsyncTimeout == 0 {
segment.Enabled = segment.writer.Enabled() segment.Enabled = segment.writer.Enabled()
} else if segment.AsyncTimeout > 0 {
segment.executeWithAsyncTimeout()
} else { } else {
done := make(chan bool) done := make(chan bool)
go func() { go func() {
@@ -147,6 +152,176 @@ func (segment *Segment) Execute(env runtime.Environment) {
} }
} }
// executeWithAsyncTimeout executes the segment with async timeout behavior
func (segment *Segment) executeWithAsyncTimeout() {
// Generate a cache key for this segment
cacheKey := segment.generateAsyncCacheKey()
// Get async cache instance
asyncCache := segment.getAsyncCache()
if asyncCache == nil {
log.Debugf("async cache not available for segment: %s", segment.Name())
segment.Enabled = segment.writer.Enabled()
return
}
// Check if we have cached data
if cachedData, found := asyncCache.GetSegmentData(segment.Name(), cacheKey); found {
// Use cached data if available
segment.Enabled = cachedData.Enabled
if cachedData.Enabled {
segment.writer.SetText(cachedData.Text)
}
log.Debugf("using cached data for segment: %s", segment.Name())
// Check if we should refresh the cache (if not already running)
if !asyncCache.IsAsyncProcessRunning(segment.Name(), cacheKey) {
go segment.refreshAsyncCache(cacheKey, asyncCache)
}
return
}
// No cached data, execute with timeout
done := make(chan bool)
go func() {
segment.Enabled = segment.writer.Enabled()
done <- true
}()
select {
case <-done:
// Completed before async timeout, cache the result
if segment.Enabled {
asyncData := &cache.AsyncSegmentData{
Text: segment.writer.Text(),
Enabled: segment.Enabled,
Timestamp: time.Now(),
Duration: segment.getCacheDuration(),
}
asyncCache.SetSegmentData(segment.Name(), cacheKey, asyncData)
}
case <-time.After(segment.AsyncTimeout * time.Millisecond):
log.Debugf("async timeout after %dms for segment: %s", segment.AsyncTimeout, segment.Name())
// Start async process to update cache
go segment.refreshAsyncCache(cacheKey, asyncCache)
// Return without enabling segment (no cached data available)
segment.Enabled = false
return
}
}
// generateAsyncCacheKey generates a unique cache key for async segments
func (segment *Segment) generateAsyncCacheKey() string {
// Include working directory and relevant segment properties
cwd := segment.env.Pwd()
segmentType := string(segment.Type)
// For git segments, include the git directory
if segmentType == "git" {
if gitDir := segment.env.Getenv("GIT_DIR"); gitDir != "" {
return fmt.Sprintf("%s_%s_%s", segmentType, cwd, gitDir)
}
}
return fmt.Sprintf("%s_%s", segmentType, cwd)
}
// getAsyncCache returns the async cache instance
func (segment *Segment) getAsyncCache() *cache.AsyncSegmentCache {
if segment.env.Cache() == nil {
return nil
}
return cache.NewAsyncSegmentCache(segment.env.Cache())
}
// getCacheDuration returns the cache duration for the segment
func (segment *Segment) getCacheDuration() cache.Duration {
if segment.Cache != nil {
return segment.Cache.Duration
}
// Default cache duration for async segments (5 minutes)
return cache.Duration("5m")
}
// refreshAsyncCache refreshes the cache in the background
func (segment *Segment) refreshAsyncCache(cacheKey string, asyncCache *cache.AsyncSegmentCache) {
segmentName := segment.Name()
// Mark as running to prevent multiple concurrent refreshes
asyncCache.SetAsyncProcessRunning(segmentName, cacheKey)
defer asyncCache.ClearAsyncProcessRunning(segmentName, cacheKey)
log.Debugf("refreshing async cache for segment: %s", segmentName)
// Create a fresh segment instance for background execution
// This is necessary because the original segment might be modified
if segment.env.Flags().Debug {
// For debug, run in the same process
segment.executeAsyncRefresh(cacheKey, asyncCache)
} else {
// For production, spawn a background process
segment.spawnAsyncRefreshProcess(cacheKey)
}
}
// executeAsyncRefresh executes the refresh in the current process
func (segment *Segment) executeAsyncRefresh(cacheKey string, asyncCache *cache.AsyncSegmentCache) {
// Execute the segment without timeout
enabled := segment.writer.Enabled()
// Cache the result
asyncData := &cache.AsyncSegmentData{
Text: segment.writer.Text(),
Enabled: enabled,
Timestamp: time.Now(),
Duration: segment.getCacheDuration(),
}
asyncCache.SetSegmentData(segment.Name(), cacheKey, asyncData)
log.Debugf("async cache updated for segment: %s", segment.Name())
}
// spawnAsyncRefreshProcess spawns a background process to refresh the cache
func (segment *Segment) spawnAsyncRefreshProcess(cacheKey string) {
// Get the current executable path
execPath, err := os.Executable()
if err != nil {
log.Debugf("failed to get executable path for async refresh: %v", err)
return
}
// Prepare command arguments for async cache refresh
args := []string{
"cache", "refresh-segment",
"--segment", segment.Name(),
"--cache-key", cacheKey,
"--working-dir", segment.env.Pwd(),
}
// Add segment-specific properties
if segment.Type == "git" {
args = append(args, "--segment-type", "git")
}
// Start background process
cmd := exec.Command(execPath, args...)
cmd.Dir = segment.env.Pwd()
// Set environment variables
cmd.Env = os.Environ()
// Start the process without waiting for it to complete
err = cmd.Start()
if err != nil {
log.Debugf("failed to start async refresh process: %v", err)
return
}
log.Debugf("started async refresh process for segment: %s", segment.Name())
}
func (segment *Segment) Render(index int, force bool) bool { func (segment *Segment) Render(index int, force bool) bool {
if !segment.Enabled && !force { if !segment.Enabled && !force {
return false return false