|
8 | 8 | "testing" |
9 | 9 | "time" |
10 | 10 |
|
| 11 | + "github.com/jandedobbeleer/oh-my-posh/src/maps" |
| 12 | + |
11 | 13 | "github.com/stretchr/testify/assert" |
12 | 14 | "github.com/stretchr/testify/require" |
13 | 15 | ) |
@@ -164,3 +166,181 @@ func TestGetSurvivesGobPointerRegistration(t *testing.T) { |
164 | 166 | require.True(t, ok, "expected a cache hit after a gob round-trip of a pointer-registered type") |
165 | 167 | assert.Equal(t, "value", got.Name) |
166 | 168 | } |
| 169 | + |
| 170 | +// Guards against the #7758 class of bug: a long-lived process (serve) that |
| 171 | +// loaded the session cache once at startup must still see a write from a |
| 172 | +// separate one-shot process (e.g. `oh-my-posh toggle`) that landed on disk |
| 173 | +// afterwards, without losing values it has itself set more recently than |
| 174 | +// what's on disk (e.g. prompt_count_cache). |
| 175 | +func TestRefreshMergesExternalWriteByTimestamp(t *testing.T) { |
| 176 | + origSession := session |
| 177 | + t.Cleanup(func() { session = origSession }) |
| 178 | + |
| 179 | + filePath := filepath.Join(t.TempDir(), "session.cache") |
| 180 | + |
| 181 | + // The daemon's in-memory state: it has its own, newer value for a key |
| 182 | + // the external writer also touches, and predates the external write. |
| 183 | + daemon := Session.new() |
| 184 | + daemon.filePath = filePath |
| 185 | + daemon.persist = true |
| 186 | + daemon.mtime = time.Now().Add(-time.Hour) |
| 187 | + daemon.cache.Set("shared_key", &Entry[any]{ |
| 188 | + Value: "daemon-value", |
| 189 | + Timestamp: time.Now().Unix(), |
| 190 | + TTL: -1, |
| 191 | + }) |
| 192 | + |
| 193 | + // Simulate `oh-my-posh toggle` running as a separate one-shot process: |
| 194 | + // it starts from a stale copy of shared_key and writes a brand new key. |
| 195 | + writer := Session.new() |
| 196 | + writer.filePath = filePath |
| 197 | + writer.persist = true |
| 198 | + writer.dirty = true |
| 199 | + writer.cache.Set("shared_key", &Entry[any]{ |
| 200 | + Value: "stale-external-copy", |
| 201 | + Timestamp: time.Now().Unix() - 100, |
| 202 | + TTL: -1, |
| 203 | + }) |
| 204 | + writer.cache.Set(TOGGLECACHE, &Entry[any]{ |
| 205 | + Value: map[string]bool{"shell": true}, |
| 206 | + Timestamp: time.Now().Unix(), |
| 207 | + TTL: -1, |
| 208 | + }) |
| 209 | + session = writer |
| 210 | + Session.close() |
| 211 | + |
| 212 | + // Back to the daemon's perspective: refresh should pick up the new |
| 213 | + // toggle_cache key from disk... |
| 214 | + session = daemon |
| 215 | + Refresh(Session) |
| 216 | + |
| 217 | + toggled, found := Get[map[string]bool](Session, TOGGLECACHE) |
| 218 | + require.True(t, found, "toggle_cache written externally should be visible after Refresh") |
| 219 | + assert.True(t, toggled["shell"]) |
| 220 | + |
| 221 | + // ...without clobbering the daemon's own newer value for a key both |
| 222 | + // sides touched. |
| 223 | + shared, found := Get[string](Session, "shared_key") |
| 224 | + require.True(t, found) |
| 225 | + assert.Equal(t, "daemon-value", shared, "a newer in-memory value must win over an older on-disk one") |
| 226 | +} |
| 227 | + |
| 228 | +// Guards against the daemon's own shutdown flush clobbering a write that |
| 229 | +// landed after its last Refresh but before it exits. |
| 230 | +func TestCloseRefreshesBeforePersistingToAvoidClobber(t *testing.T) { |
| 231 | + origSession := session |
| 232 | + t.Cleanup(func() { session = origSession }) |
| 233 | + |
| 234 | + filePath := filepath.Join(t.TempDir(), "session.cache") |
| 235 | + |
| 236 | + daemon := Session.new() |
| 237 | + daemon.filePath = filePath |
| 238 | + daemon.persist = true |
| 239 | + daemon.dirty = true |
| 240 | + daemon.mtime = time.Now().Add(-time.Hour) |
| 241 | + daemon.cache.Set("prompt_count_cache", &Entry[any]{ |
| 242 | + Value: 3, |
| 243 | + Timestamp: time.Now().Unix(), |
| 244 | + TTL: -1, |
| 245 | + }) |
| 246 | + |
| 247 | + // A toggle write lands on disk after the daemon's last refresh, right |
| 248 | + // before the shell (and daemon) exit. |
| 249 | + writer := Session.new() |
| 250 | + writer.filePath = filePath |
| 251 | + writer.persist = true |
| 252 | + writer.dirty = true |
| 253 | + writer.cache.Set(TOGGLECACHE, &Entry[any]{ |
| 254 | + Value: map[string]bool{"shell": true}, |
| 255 | + Timestamp: time.Now().Unix(), |
| 256 | + TTL: -1, |
| 257 | + }) |
| 258 | + session = writer |
| 259 | + Session.close() |
| 260 | + |
| 261 | + // The daemon now shuts down. Its close() must merge the external write |
| 262 | + // in before overwriting the file, not blindly persist its stale copy. |
| 263 | + session = daemon |
| 264 | + Session.close() |
| 265 | + |
| 266 | + reader, err := openFile(filePath) |
| 267 | + require.NoError(t, err) |
| 268 | + defer reader.Close() |
| 269 | + |
| 270 | + var onDisk maps.Simple[*Entry[any]] |
| 271 | + require.NoError(t, gob.NewDecoder(reader).Decode(&onDisk)) |
| 272 | + |
| 273 | + session = &store{cache: onDisk.ToConcurrent()} |
| 274 | + |
| 275 | + toggled, found := Get[map[string]bool](Session, TOGGLECACHE) |
| 276 | + require.True(t, found, "the external write must survive the daemon's own shutdown flush") |
| 277 | + assert.True(t, toggled["shell"]) |
| 278 | + |
| 279 | + count, found := Get[int](Session, "prompt_count_cache") |
| 280 | + require.True(t, found) |
| 281 | + assert.Equal(t, 3, count) |
| 282 | +} |
| 283 | + |
| 284 | +// Guards against a variant of #7758: the serve daemon's Device store (which |
| 285 | +// holds the RELOAD flag config.Get checks in prompt.New to bypass its own |
| 286 | +// config cache) must also see a write from a separate `oh-my-posh enable |
| 287 | +// reload` process, not just the Session store. |
| 288 | +func TestRefreshPicksUpDeviceStoreWrite(t *testing.T) { |
| 289 | + origDevice := device |
| 290 | + t.Cleanup(func() { device = origDevice }) |
| 291 | + |
| 292 | + filePath := filepath.Join(t.TempDir(), "omp.cache") |
| 293 | + |
| 294 | + daemon := Device.new() |
| 295 | + daemon.filePath = filePath |
| 296 | + daemon.persist = true |
| 297 | + daemon.mtime = time.Now().Add(-time.Hour) |
| 298 | + device = daemon |
| 299 | + |
| 300 | + writer := Device.new() |
| 301 | + writer.filePath = filePath |
| 302 | + writer.persist = true |
| 303 | + writer.dirty = true |
| 304 | + writer.cache.Set("reload", &Entry[any]{ |
| 305 | + Value: true, |
| 306 | + Timestamp: time.Now().Unix(), |
| 307 | + TTL: -1, |
| 308 | + }) |
| 309 | + device = writer |
| 310 | + Device.close() |
| 311 | + |
| 312 | + device = daemon |
| 313 | + Refresh(Device) |
| 314 | + |
| 315 | + reload, found := Get[bool](Device, "reload") |
| 316 | + require.True(t, found, "`enable reload` written externally should be visible after Refresh") |
| 317 | + assert.True(t, reload) |
| 318 | +} |
| 319 | + |
| 320 | +// Device's close() must bump the file's mtime as reliably as Session's does |
| 321 | +// (the mmap-backed Windows write path doesn't do this on its own) - without |
| 322 | +// it, Refresh would never notice an `enable`/`disable` write on Windows. |
| 323 | +func TestStoreCloseTouchesDeviceFileMTime(t *testing.T) { |
| 324 | + origDevice := device |
| 325 | + t.Cleanup(func() { device = origDevice }) |
| 326 | + |
| 327 | + filePath := filepath.Join(t.TempDir(), "omp.cache") |
| 328 | + |
| 329 | + testStore := Device.new() |
| 330 | + testStore.filePath = filePath |
| 331 | + testStore.persist = true |
| 332 | + testStore.dirty = true |
| 333 | + testStore.cache.Set("reload", &Entry[any]{ |
| 334 | + Value: true, |
| 335 | + Timestamp: time.Now().Unix(), |
| 336 | + TTL: -1, |
| 337 | + }) |
| 338 | + device = testStore |
| 339 | + |
| 340 | + before := time.Now() |
| 341 | + Device.close() |
| 342 | + |
| 343 | + info, err := os.Stat(filePath) |
| 344 | + require.NoError(t, err) |
| 345 | + assert.False(t, info.ModTime().Before(before), "mtime should be bumped to the close time, not left stale") |
| 346 | +} |
0 commit comments