Skip to content

Commit 367a127

Browse files
committed
perf(vscode): flatten cold-reload startup cost
v0.4.1 Pass over the activate() critical path and the cockpit refresh loop. None of the changes here need to be opted into — they apply on every reload. Persisted hot-path snapshots across window reloads via a new core/persistedCache.ts helper (workspaceState wrapper). Bazel disk-cache size, environment versions, and cmk-dev-install-site state are read synchronously from yesterday's snapshot on first paint and revalidated in the background — the user no longer stares at empty Environment tiles while du and a fistful of --version subprocesses spin up. Bazel disk-cache probe short-circuits via fs.statSync on the cache dir mtime; when it is unchanged since the last probe, the expensive du -sb walk is skipped entirely. loadConfig() now caches the parsed JSON keyed by file mtime, so the repeated reads in refreshStateCache and the settings dialog collapse to a single parse. registerBazelTestController + registerBazelTestsConfigView are deferred until the first Python / TypeScript editor activates or 10 s elapse, freeing the activate() path for vscode.git and Pylance startup. checkVersionMismatch and showWhatsNewIfNeeded are deferred by 2 s so neither can pop a modal during the activate window. The 5 s cockpit auto-refresh tick is skipped whenever the cockpit webview is hidden — eliminates ~720 HTML rebuilds per hour of editor work where the sidebar is not visible. refreshOverview() now re-reads the gitState and bazelCache stale- while-revalidate slices before rendering. Without this, the 5 s tick (and the SWR callbacks) read back the frozen snapshot from the last full refreshStateCache(), so re-enabling the pre-commit hook would leave the cockpit's "bypassed" chip stuck on the previous state until the user triggered refreshAll(). New invalidateGitState() helper plus a .git/hooks/pre-commit* file watcher in sidebar.ts: the watcher invalidates gitState's SWR cache and calls refreshAll() so the cockpit's "pre-commit bypassed" chip flips within ~100 ms of toggling the hook instead of waiting out the 5 s TTL. The startup-regression cockpit chip now requires cmk.benchmark- Startup to be on so stale history from a previous experiment cannot surface a phantom warning after the flag is turned off. The cmk.fixQaTestDataSubmodule reset now reads the tracked gitlink SHA from the parent index and checks the submodule worktree out at that SHA directly. The previous `git submodule update --init --force` path fell back to the relative ../qa-test-data URL in .gitmodules, which git 2.38+ refuses over the file:// transport — the action used to fail with "fatal: transport 'file' not allowed" even when the submodule was already correct. AI-Generated: true Change-Id: I2956fb7080eb5ee00f36f848a50f088672e6fcf5
1 parent 82d6a58 commit 367a127

10 files changed

Lines changed: 300 additions & 20 deletions

File tree

.ide/vscode/changelog/v0.4.1.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## v0.4.1
2+
3+
> **Note:** Startup-perf pass aimed at flattening cold-reload cost. None of
4+
> the changes below need to be opted into — they apply on every reload.
5+
6+
### Persistence across window reloads
7+
8+
- **perf**: persist the Bazel disk-cache size snapshot in `workspaceState`; the next reload returns yesterday's value synchronously instead of waiting for `du -sb` to complete
9+
- **perf**: persist the environment-versions snapshot (Python / Node / Bazel / Bazelisk / Docker / GCC) so the Environment grid paints from the previous session immediately
10+
- **perf**: persist the `cmk-dev-install-site` install state so the OMD section's "Create Site" button is correct on first paint without re-shelling out
11+
- New `src/core/persistedCache.ts` helper centralises the load/save of these workspace-state snapshots
12+
13+
### Skip work that hasn't changed
14+
15+
- **perf**: short-circuit the Bazel disk-cache probe when the cache directory's mtime is unchanged since the last run — `du -sb` is replaced by a single `fs.statSync` on hot paths
16+
- **perf**: `loadConfig()` reads the JSON file only when its mtime changes; subsequent calls (every `refreshStateCache`, every settings dialog) return the cached parsed object
17+
18+
### Activate critical path
19+
20+
- **perf**: defer `registerBazelTestController` + `registerBazelTestsConfigView` until the first Python / TypeScript editor activates, or 10 s elapse — whichever fires first
21+
- **perf**: defer `checkVersionMismatch` + `showWhatsNewIfNeeded` by 2 s so neither can pop a modal during the activate window
22+
23+
### Sidebar churn
24+
25+
- **perf**: the 5 s cockpit auto-refresh tick is skipped while the cockpit webview is hidden — saves ~720 HTML rebuilds per hour of editor work
26+
27+
### Cockpit freshness
28+
29+
- **fix**: `refreshOverview()` now re-reads the gitState and bazelCache stale-while-revalidate slices before rendering — re-enabling the pre-commit hook (or any other state covered by a SWR getter) clears the cockpit chip on the next 5 s tick instead of being stuck on the frozen snapshot from the last full state refresh
30+
- **fix**: new `.git/hooks/pre-commit*` watcher invalidates gitState's TTL and triggers `refreshAll()` on toggle, so the cockpit chip flips within ~100 ms instead of waiting for the next 5 s tick
31+
- **fix**: `cmk.fixQaTestDataSubmodule` no longer shells out to `git submodule update --init --force --checkout`, which on git 2.38+ falls back to the relative `../qa-test-data` URL in `.gitmodules` and fails with `fatal: transport 'file' not allowed`. Reads the tracked gitlink SHA via `git ls-tree HEAD tests/qa-test-data` and checks the submodule worktree out at that SHA directly, fetching only when the SHA is not already local
32+
33+
### Startup regression banner
34+
35+
- **fix**: the cockpit's "Startup performance" regression chip now only fires when the benchmark setting is on; stale history from a previous experiment can no longer surface a phantom warning

.ide/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "cmk-vscode",
33
"displayName": "CMK Dev Tools for VS Code",
44
"description": "Build commands and helpers for Checkmk development",
5-
"version": "0.4.0",
5+
"version": "0.4.1",
66
"publisher": "checkmk",
77
"engines": {
88
"vscode": "^1.85.0"

.ide/vscode/src/build/bazelCache.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as path from 'path'
99
import * as vscode from 'vscode'
1010

1111
import { error, log, notifyError, notifyInfo } from '../core/log'
12+
import { loadPersisted, savePersisted } from '../core/persistedCache'
1213
import { safeExecAsync } from '../core/shell'
1314
import { runCommand, waitForTask } from '../core/tasks'
1415

@@ -21,6 +22,13 @@ export interface BazelCacheSnapshot {
2122

2223
const DEFAULT_THRESHOLD_GIB = 50
2324
const TTL_MS = 5 * 60 * 1000
25+
const PERSIST_KEY = 'cmk.bazelCache.snapshot'
26+
27+
interface PersistedSnapshot {
28+
snapshot: BazelCacheSnapshot
29+
cacheDirMtimeMs: number
30+
timestamp: number
31+
}
2432

2533
const EMPTY: BazelCacheSnapshot = {
2634
sizeBytes: null,
@@ -31,8 +39,28 @@ const EMPTY: BazelCacheSnapshot = {
3139

3240
let _snapshot: BazelCacheSnapshot = EMPTY
3341
let _lastUpdated = 0
42+
let _lastCacheDirMtimeMs = 0
3443
let _inflight: Promise<void> | null = null
3544
let _onRefresh: (() => void) | null = null
45+
let _hydrated = false
46+
47+
function hydrateFromPersisted(): void {
48+
if (_hydrated) return
49+
_hydrated = true
50+
const persisted = loadPersisted<PersistedSnapshot>(PERSIST_KEY)
51+
if (!persisted) return
52+
_snapshot = persisted.snapshot
53+
_lastUpdated = persisted.timestamp
54+
_lastCacheDirMtimeMs = persisted.cacheDirMtimeMs
55+
}
56+
57+
function cacheDirMtimeMs(cachePath: string): number {
58+
try {
59+
return fs.statSync(cachePath).mtimeMs
60+
} catch {
61+
return 0
62+
}
63+
}
3664

3765
export function setBazelCacheRefreshCallback(cb: (() => void) | null): void {
3866
_onRefresh = cb
@@ -89,6 +117,8 @@ function shellQuote(s: string): string {
89117
* the probe completes and triggers `_onRefresh`.
90118
*/
91119
export function getBazelCacheSnapshot(): BazelCacheSnapshot {
120+
hydrateFromPersisted()
121+
92122
const wsPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
93123
if (!wsPath) return EMPTY
94124

@@ -106,6 +136,28 @@ export function getBazelCacheSnapshot(): BazelCacheSnapshot {
106136
if (!cachePath || !fs.existsSync(cachePath)) {
107137
_snapshot = { ...EMPTY, thresholdGiB, cachePath }
108138
_lastUpdated = Date.now()
139+
_lastCacheDirMtimeMs = 0
140+
persistSnapshot()
141+
_onRefresh?.()
142+
return
143+
}
144+
// Cheap mtime stat: if the cache dir hasn't been touched since the
145+
// last probe, the bytes count cannot have changed and we can skip
146+
// the expensive `du -sb` walk entirely.
147+
const dirMtime = cacheDirMtimeMs(cachePath)
148+
if (
149+
dirMtime > 0 &&
150+
dirMtime === _lastCacheDirMtimeMs &&
151+
_snapshot.sizeBytes !== null &&
152+
_snapshot.cachePath === cachePath
153+
) {
154+
_snapshot = {
155+
..._snapshot,
156+
thresholdGiB,
157+
overThreshold: _snapshot.sizeBytes > thresholdGiB * 1024 ** 3
158+
}
159+
_lastUpdated = Date.now()
160+
persistSnapshot()
109161
_onRefresh?.()
110162
return
111163
}
@@ -117,6 +169,8 @@ export function getBazelCacheSnapshot(): BazelCacheSnapshot {
117169
overThreshold: sizeBytes !== null && sizeBytes > thresholdGiB * 1024 ** 3
118170
}
119171
_lastUpdated = Date.now()
172+
_lastCacheDirMtimeMs = dirMtime
173+
persistSnapshot()
120174
_onRefresh?.()
121175
} catch (err) {
122176
error(`bazelCache probe failed: ${(err as Error).message}`)
@@ -129,6 +183,14 @@ export function getBazelCacheSnapshot(): BazelCacheSnapshot {
129183
return view
130184
}
131185

186+
function persistSnapshot(): void {
187+
savePersisted<PersistedSnapshot>(PERSIST_KEY, {
188+
snapshot: _snapshot,
189+
cacheDirMtimeMs: _lastCacheDirMtimeMs,
190+
timestamp: _lastUpdated
191+
})
192+
}
193+
132194
export function registerBazelCache(): vscode.Disposable[] {
133195
return [
134196
vscode.commands.registerCommand('cmk.bazel.cleanDiskCache', async () => {

.ide/vscode/src/core/config.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,36 @@ export function shellEscape(s: string): string {
6464
return "'" + String(s).replace(/'/g, "'\\''") + "'"
6565
}
6666

67+
interface ConfigCacheEntry {
68+
path: string
69+
mtimeMs: number
70+
parsed: unknown
71+
}
72+
const _configCache = new Map<string, ConfigCacheEntry>()
73+
74+
function readConfigCached(filePath: string): unknown {
75+
let mtimeMs: number
76+
try {
77+
mtimeMs = fs.statSync(filePath).mtimeMs
78+
} catch {
79+
throw new Error(`config not found: ${filePath}`)
80+
}
81+
const cached = _configCache.get(filePath)
82+
if (cached && cached.mtimeMs === mtimeMs) return cached.parsed
83+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
84+
_configCache.set(filePath, { path: filePath, mtimeMs, parsed })
85+
return parsed
86+
}
87+
6788
export function loadConfig<T = unknown>(name: string): T {
6889
// Prefer workspace config (branch-aware, always fresh)
6990
const wsPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
7091
if (wsPath) {
7192
const wsConfig = path.join(wsPath, '.ide', 'vscode', 'config', `${name}.json`)
72-
if (fs.existsSync(wsConfig)) {
73-
return JSON.parse(fs.readFileSync(wsConfig, 'utf8'))
74-
}
93+
if (fs.existsSync(wsConfig)) return readConfigCached(wsConfig) as T
7594
}
7695
// Fallback to bundled config in installed VSIX
77-
return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', `${name}.json`), 'utf8'))
96+
return readConfigCached(path.join(__dirname, '..', 'config', `${name}.json`)) as T
7897
}
7998

8099
/**
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
* This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
* conditions defined in the file COPYING, which is part of this source code package.
5+
*/
6+
import type * as vscode from 'vscode'
7+
8+
/**
9+
* Thin typed wrapper around `context.workspaceState` used to keep
10+
* stale-while-revalidate snapshots warm across window reloads, so the first
11+
* paint after a window reload renders from yesterday's value instead of
12+
* waiting on a subprocess.
13+
*/
14+
let _context: vscode.ExtensionContext | null = null
15+
16+
export function bindPersistedCacheContext(context: vscode.ExtensionContext): void {
17+
_context = context
18+
}
19+
20+
export function loadPersisted<T>(key: string): T | null {
21+
if (!_context) return null
22+
return _context.workspaceState.get<T>(key, null as unknown as T) ?? null
23+
}
24+
25+
export function savePersisted<T>(key: string, value: T): void {
26+
if (!_context) return
27+
void _context.workspaceState.update(key, value)
28+
}

.ide/vscode/src/extension.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
writeSetting
1919
} from './core/config'
2020
import { error, log, notifyInfo, registerErrorHandlers } from './core/log'
21+
import { bindPersistedCacheContext } from './core/persistedCache'
2122
import { checkVersionMismatch } from './core/versionCheck'
2223
import { checkForUpdates, isInstalledAsync as isDevSiteInstalledAsync } from './omd/devSiteTools'
2324
import { registerLogs } from './omd/logs'
@@ -91,6 +92,7 @@ function toggleSettings(
9192
export function activate(context: vscode.ExtensionContext): void {
9293
log('Extension activating')
9394
registerErrorHandlers()
95+
bindPersistedCacheContext(context)
9496
const commands = loadConfig<Record<string, CommandEntry>>('commands')
9597
const extensionSets = loadConfig<ExtensionSets>('extensions')
9698
const settingsSets = loadConfig<Record<string, SettingsEntry>>('settings')
@@ -156,8 +158,38 @@ export function activate(context: vscode.ExtensionContext): void {
156158
})
157159
)
158160

159-
// Bazel test discovery + runner — always on; serves both py_test and vitest_test targets.
160-
context.subscriptions.push(...registerBazelTestController(), ...registerBazelTestsConfigView())
161+
// Bazel test discovery + runner — deferred until the first Python /
162+
// TypeScript editor activates, or 10 s elapse, whichever fires first. The
163+
// test controller's own discovery is already lazy, but the registration
164+
// itself does enough work to be worth keeping off the activate() path.
165+
let bazelTestsRegistered = false
166+
const registerBazelTestsOnce = (): void => {
167+
if (bazelTestsRegistered) return
168+
bazelTestsRegistered = true
169+
disposeBazelTestsTriggers()
170+
context.subscriptions.push(...registerBazelTestController(), ...registerBazelTestsConfigView())
171+
}
172+
const bazelTestsTriggers: vscode.Disposable[] = []
173+
const disposeBazelTestsTriggers = (): void => {
174+
for (const d of bazelTestsTriggers) {
175+
try {
176+
d.dispose()
177+
} catch {
178+
// ignore
179+
}
180+
}
181+
}
182+
bazelTestsTriggers.push(
183+
vscode.window.onDidChangeActiveTextEditor((ed) => {
184+
if (!ed) return
185+
const lang = ed.document.languageId
186+
if (lang === 'python' || lang === 'typescript' || lang === 'typescriptreact')
187+
registerBazelTestsOnce()
188+
})
189+
)
190+
const bazelTestsFallback = setTimeout(registerBazelTestsOnce, 10_000)
191+
bazelTestsTriggers.push({ dispose: () => clearTimeout(bazelTestsFallback) })
192+
context.subscriptions.push(...bazelTestsTriggers)
161193

162194
// --- Family-gated features ---
163195

@@ -231,12 +263,19 @@ export function activate(context: vscode.ExtensionContext): void {
231263
profileManager.setOnRefresh(refreshAll)
232264
profileManager.init(context)
233265
registerProfileDetector(context)
234-
checkVersionMismatch(context)
235266

236267
context.subscriptions.push(...registerWhatsNew(context))
237-
showWhatsNewIfNeeded(context).catch((err) =>
238-
error(`What's new failed: ${(err as Error).message}`)
239-
)
268+
269+
// Defer the version-mismatch dialog and the What's New popup by 2 s —
270+
// neither needs to land before the user can interact, and both can pop
271+
// blocking modals that drag the perceived activate path.
272+
const deferred = setTimeout(() => {
273+
checkVersionMismatch(context)
274+
showWhatsNewIfNeeded(context).catch((err) =>
275+
error(`What's new failed: ${(err as Error).message}`)
276+
)
277+
}, 2000)
278+
context.subscriptions.push({ dispose: () => clearTimeout(deferred) })
240279

241280
context.subscriptions.push(...registerStartupBenchmarks(context))
242281

.ide/vscode/src/omd/devSiteTools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as vscode from 'vscode'
77

88
import { error, log } from '../core/log'
9+
import { loadPersisted, savePersisted } from '../core/persistedCache'
910
import { safeExecAsync } from '../core/shell'
1011
import { runCommand, waitForTask } from '../core/tasks'
1112
import { versionNewer } from '../core/version'
@@ -70,19 +71,29 @@ export interface DevSiteToolsState {
7071
}
7172

7273
const EMPTY_STATE: DevSiteToolsState = { installed: false, installedVersion: '' }
74+
const PERSIST_KEY = 'cmk.devSite.snapshot'
7375

7476
let _stateCache: { state: DevSiteToolsState; timestamp: number } | null = null
7577
let _stateFetchPromise: Promise<void> | null = null
7678
let _onDevSiteRefresh: (() => void) | null = null
79+
let _devSiteHydrated = false
7780

7881
export function setDevSiteRefreshCallback(cb: () => void): void {
7982
_onDevSiteRefresh = cb
8083
}
8184

85+
function hydrateDevSiteFromPersisted(): void {
86+
if (_devSiteHydrated) return
87+
_devSiteHydrated = true
88+
const persisted = loadPersisted<{ state: DevSiteToolsState; timestamp: number }>(PERSIST_KEY)
89+
if (persisted) _stateCache = persisted
90+
}
91+
8292
/** Sync getter used during sidebar render. Returns the last known value (or
8393
* empty placeholders) immediately and triggers a background refresh that
8494
* re-renders the sidebar when the new value lands. */
8595
export function getDevSiteToolsState(): DevSiteToolsState {
96+
hydrateDevSiteFromPersisted()
8697
if (_stateCache && Date.now() - _stateCache.timestamp < DEV_SITE_CACHE_TTL) {
8798
return _stateCache.state
8899
}
@@ -100,6 +111,7 @@ async function scheduleDevSiteRefresh(): Promise<void> {
100111
installedVersion
101112
}
102113
_stateCache = { state, timestamp: Date.now() }
114+
savePersisted(PERSIST_KEY, _stateCache)
103115
_onDevSiteRefresh?.()
104116
} catch (err) {
105117
error(`devSiteTools refresh failed: ${(err as Error).message}`)

.ide/vscode/src/scm/gitState.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export function setGitStateRefreshCallback(cb: (() => void) | null): void {
3333
_onRefresh = cb
3434
}
3535

36+
/**
37+
* Force the next `getGitState()` to re-probe, regardless of TTL. Used by
38+
* external watchers (e.g. the `.git/hooks/pre-commit*` watcher in sidebar.ts)
39+
* that already know the underlying state has changed and want the cockpit
40+
* to converge on the next refresh without waiting for the 5 s TTL.
41+
*/
42+
export function invalidateGitState(): void {
43+
_state = { ..._state, lastUpdated: 0 }
44+
}
45+
3646
/**
3747
* Hard-reset `tests/qa-test-data` to the gitlink tracked by the parent repo,
3848
* discarding any local changes inside the submodule. Asks for confirmation
@@ -89,13 +99,20 @@ export function registerGitFixers(): vscode.Disposable[] {
8999
)
90100
if (choice !== 'Reset') return
91101
log('git: hard-resetting tests/qa-test-data to tracked gitlink')
92-
// Reset the submodule's working tree to HEAD, drop untracked files, then
93-
// pull the gitlink commit checked out forcefully. Done as one task in a
94-
// visible terminal so the user can see what happened.
102+
// Read the tracked gitlink SHA from the parent's index and checkout the
103+
// submodule worktree at exactly that SHA. Avoids `git submodule update
104+
// --init --force` because that falls back to the relative `../qa-test-
105+
// data` URL declared in .gitmodules, which git 2.38+ refuses over the
106+
// `file://` transport — so the action would fail with "fatal: transport
107+
// 'file' not allowed" even when the submodule is already correct.
108+
// If the tracked SHA happens to be missing locally, we fetch first.
95109
const inner = [
110+
"TRACKED_SHA=$(git ls-tree HEAD tests/qa-test-data | awk '{print $3}')",
111+
'test -n "$TRACKED_SHA" || { echo "qa-test-data not tracked in HEAD"; exit 1; }',
96112
'git -C tests/qa-test-data reset --hard',
97113
'git -C tests/qa-test-data clean -fdx',
98-
'git submodule update --init --force --checkout tests/qa-test-data'
114+
'git -C tests/qa-test-data cat-file -e "$TRACKED_SHA" 2>/dev/null || git -C tests/qa-test-data fetch origin',
115+
'git -C tests/qa-test-data checkout --force --detach "$TRACKED_SHA"'
99116
].join(' && ')
100117
const exec = runCommand('Reset tests/qa-test-data', inner)
101118
if (!exec) {

0 commit comments

Comments
 (0)