Skip to content

Core: Clean stale runtime instance records - #35023

Merged
kasperpeulen merged 1 commit into
nextfrom
kasper/runtime-registry-cleanup
Jun 2, 2026
Merged

kasperpeulen merged 1 commit into
nextfrom
kasper/runtime-registry-cleanup

Conversation

@kasperpeulen

@kasperpeulen kasperpeulen commented Jun 2, 2026 •

Copy link
Copy Markdown
Member

Closes #35022

What I did

  • Added a best-effort runtime instance registry sweep before writing the current process record.
  • Implemented the cleanup policy from Tracking: make runtime instance registry cleanup resilient #35022:
    • keep records younger than 24h without PID checks
    • for records from 24h through 7d, remove only when process.kill(pid, 0) definitely reports ESRCH
    • keep active or ambiguous PID checks
    • remove valid records older than 7d unconditionally
    • remove malformed JSON only when file mtime is older than 7d
    • remove .tmp files only when file mtime is older than 24h
  • Kept the existing temp-file-and-rename write path intact.
  • Split the policy into a pure decision helper and a small IO shell so the retention rules are easy to review and test.
  • Completed an independent cursor-team-kit:thermo-nuclear-code-quality-review subagent review. The review findings were addressed by extracting the pure cleanup decision helper, converting the policy coverage to compact table tests, and fixing the unreadable-file vs malformed-JSON boundary. The final re-review found no remaining actionable maintainability issues.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

Caution

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Automated verification run locally:

yarn nx compile core
yarn fmt:write
yarn --cwd code lint:js:cmd core/src/core-server/utils/runtime-instance-registry.ts core/src/core-server/utils/runtime-instance-registry.test.ts --fix
yarn vitest run --config code/core/vitest.config.ts code/core/src/core-server/utils/runtime-instance-registry.test.ts
git diff --check

Manual QA was performed by running real Storybook dev servers with temporary HOME directories so the runtime registry was isolated at $HOME/.storybook/instances.

Commands used:

HOME=/tmp/storybook-dev-registry-qa-p0FLgA STORYBOOK_DISABLE_TELEMETRY=1 NODE_OPTIONS="--max_old_space_size=4096 --trace-deprecation" node code/core/dist/bin/dispatcher.js dev --port 61234 --config-dir code/.storybook --ci --no-open --exact-port
HOME=/tmp/storybook-dev-registry-qa-active-FTSbc7 STORYBOOK_DISABLE_TELEMETRY=1 NODE_OPTIONS="--max_old_space_size=4096 --trace-deprecation" node code/core/dist/bin/dispatcher.js dev --port 61235 --config-dir code/.storybook --ci --no-open --exact-port

Observed:

  • Storybook dev reached Storybook ready! on http://localhost:61234/ and http://localhost:61235/.
  • curl -I returned HTTP/1.1 200 OK for both ports while Storybook was running.
  • A current runtime instance record was written with the live Storybook PID, URL, port, cwd, and version.
  • Sending SIGTERM to Storybook removed its current runtime instance record.
  • The seeded cleanup matrix behaved as expected:
    • recent valid record stayed
    • recent .tmp stayed
    • stale inactive PID record was removed
    • stale active PID record stayed
    • valid record older than 7 days was removed even with an active PID
    • old .tmp was removed
    • old malformed JSON was removed
    • recent malformed JSON stayed
{
  "registry": "/tmp/storybook-dev-registry-qa-active-FTSbc7/.storybook/instances",
  "remaining": [
    "368519e1-baca-4248-ba5b-e1d9ba7c74e2.json",
    "recent-valid.json",
    "recent.tmp",
    "stale-active.json"
  ],
  "expectedPresent": [
    "recent-valid.json",
    "recent.tmp",
    "stale-active.json"
  ],
  "expectedAbsent": [
    "stale-inactive.json",
    "old-valid-active-pid.json",
    "old.tmp"
  ],
  "pass": true
}

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

No docs update: this is internal runtime registry maintenance behavior.

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved cleanup of stale and temporary registry files before writing new records to enhance system stability.
    • Enhanced system robustness when file cleanup encounters errors, ensuring record operations succeed despite cleanup failures.
  • Tests

    • Expanded test coverage for registry cleanup behavior with deterministic time-based assertions and new cleanup decision validation tests.

@kasperpeulen kasperpeulen added maintenance User-facing maintenance tasks ci:normal Run our default set of CI jobs (choose this for most PRs). labels Jun 2, 2026
@kasperpeulen
kasperpeulen marked this pull request as ready for review June 2, 2026 06:57
@kasperpeulen
kasperpeulen requested a review from huang-julien June 2, 2026 06:57
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds automatic cleanup of stale and malformed runtime instance records from disk. The implementation introduces cleanup decision logic that determines whether registry entries should be kept or removed based on file age, timestamp validity, and process liveness, integrates this cleanup into the write path before persisting new records, and extends tests to validate cleanup behavior and write robustness.

Changes

Registry cleanup mechanism

Layer / File(s) Summary
Cleanup types and time constants
code/core/src/core-server/utils/runtime-instance-registry.ts
Adds RuntimeInstanceRegistryCleanupEntry and RuntimeInstanceRegistryCleanupDecision exported types, updates fs imports to include promise-based stat and rm utilities, and defines one-day and seven-day millisecond constants for cleanup age thresholds.
Cleanup decision and registry scanning
code/core/src/core-server/utils/runtime-instance-registry.ts
Implements getRuntimeInstanceRegistryCleanupDecision to compute keep/remove/check-pid decisions using file mtime age, parsed record updatedAt/startedAt timestamps, and PID liveness checks via process.kill(pid, 0). Adds cleanupRuntimeInstanceRegistry to scan .tmp and .json files in the registry directory and apply removal decisions with per-file error tolerance.
Write path integration
code/core/src/core-server/utils/runtime-instance-registry.ts
writeRuntimeInstanceRecord now calls cleanupRuntimeInstanceRegistry(registryDir) immediately before writing the new instance record to remove stale entries.
Test setup and comprehensive coverage
code/core/src/core-server/utils/runtime-instance-registry.test.ts
Test imports and setup expanded with deterministic time helpers (NOW, hoursAgo, daysAgo), cleanup function imports, and fake-timer restoration in afterEach. New parameterized test suite validates getRuntimeInstanceRegistryCleanupDecision across temp files, malformed JSON, and valid records with PID validation and timestamp fallback logic. Write tests expanded to verify stale-entry sweeping with process-kill attempts and added resilience tests ensuring writes complete when cleanup throws EBUSY or when stale JSON cannot be read due to EACCES.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • storybookjs/storybook#34863: Extends runtime instance registry with directory-scanning cleanup logic and modifies writeRuntimeInstanceRecord to sweep stale temp/malformed entries, directly impacting the same registry writing pathway and test coverage.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
code/core/src/core-server/utils/runtime-instance-registry.test.ts (1)

205-308: ⚡ Quick win

Add exact-threshold cases for the 24h and 7d edges.

The table currently checks values just inside/outside each window, but not the exact boundaries that the helper implements with strict comparisons. Please pin at least 24h for temp files and 7d for malformed/record entries so a future >/>= regression does not slip through.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/core-server/utils/runtime-instance-registry.test.ts` around
lines 205 - 308, Add tests that pin the exact threshold boundaries used by
getRuntimeInstanceRegistryCleanupDecision: add cases where fileModifiedAtMs
equals NOW - 24 hours (use hoursAgo(24).getTime()) for temp-file/record
scenarios and where fileModifiedAtMs equals NOW - 7 days (use
daysAgo(7).getTime()) for malformed-json and record scenarios; assert the
expected actions that match the helper's strict comparisons (keep vs remove or
check-pid) and reuse existing helpers/symbols (hoursAgo, daysAgo, NOW,
recentRecord, getRuntimeInstanceRegistryCleanupDecision) so future >/>=
regressions are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@code/core/src/core-server/utils/runtime-instance-registry.test.ts`:
- Around line 374-445: The test is dynamically mocking 'node:fs/promises' inline
with vi.resetModules()/vi.doMock() and re-imports (creating flaky, non-top-level
mocks); instead hoist the mock to the top of runtime-instance-registry.test.ts
using vi.mock('node:fs/promises', () => ({ ...actual... }), { spy: true }),
remove vi.resetModules()/vi.doMock()/vi.doUnmock()/dynamic imports, and move
per-test behavior into beforeEach/afterEach where you call
vi.mocked(fsPromises).rm.mockImplementationOnce(...) or similar to throw EBUSY
for the lockedRecordPath; update references to use the normal static import
names (and the existing createRuntimeInstanceRecord/writeRuntimeInstanceRecord
symbols) so tests no longer re-import modules under mocked fs.

---

Nitpick comments:
In `@code/core/src/core-server/utils/runtime-instance-registry.test.ts`:
- Around line 205-308: Add tests that pin the exact threshold boundaries used by
getRuntimeInstanceRegistryCleanupDecision: add cases where fileModifiedAtMs
equals NOW - 24 hours (use hoursAgo(24).getTime()) for temp-file/record
scenarios and where fileModifiedAtMs equals NOW - 7 days (use
daysAgo(7).getTime()) for malformed-json and record scenarios; assert the
expected actions that match the helper's strict comparisons (keep vs remove or
check-pid) and reuse existing helpers/symbols (hoursAgo, daysAgo, NOW,
recentRecord, getRuntimeInstanceRegistryCleanupDecision) so future >/>=
regressions are caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eaf64e6a-cccc-4bcf-86b4-9cedebd313b8

📥 Commits

Reviewing files that changed from the base of the PR and between c4f53de and 43a4ec1.

📒 Files selected for processing (2)
  • code/core/src/core-server/utils/runtime-instance-registry.test.ts
  • code/core/src/core-server/utils/runtime-instance-registry.ts

Comment on lines +374 to +445
const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => {
if (pid === 202) {
throw Object.assign(new Error('missing process'), { code: 'ESRCH' });
}

if (pid === 404) {
throw Object.assign(new Error('permission denied'), { code: 'EPERM' });
}

return true;
});

await writeCurrentRecord(registryDir);

expect(readdirSync(registryDir).sort()).toEqual([
'current-instance.json',
'recent-malformed.json',
'recent-valid.json',
'recent.tmp',
'stale-active.json',
'stale-ambiguous.json',
]);
expect(killSpy.mock.calls.map(([pid]) => pid).sort()).toEqual([202, 303, 404]);
});

it('does not block writing the current record when one stale file cannot be removed', async () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);

const registryDir = makeTempDir();
const lockedRecordPath = writeRecordFile({
instanceId: 'locked-instance',
registryDir,
updatedAt: daysAgo(8).toISOString(),
});
const actualFsPromises =
await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');

vi.resetModules();
vi.doMock('node:fs/promises', () => ({
...actualFsPromises,
rm: vi.fn(async (path, options) => {
if (path === lockedRecordPath) {
throw Object.assign(new Error('locked'), { code: 'EBUSY' });
}

return actualFsPromises.rm(path, options);
}),
}));

try {
const {
createRuntimeInstanceRecord: createRuntimeInstanceRecordWithMockedFs,
writeRuntimeInstanceRecord: writeRuntimeInstanceRecordWithMockedFs,
} = await import('./runtime-instance-registry.ts');
const currentRecordPath = await writeRuntimeInstanceRecordWithMockedFs(
createRuntimeInstanceRecordWithMockedFs({
address: 'http://localhost:7007/',
instanceId: 'current-instance',
now: NOW,
pid: 7007,
port: 7007,
storybookVersion: '10.5.0-alpha.0',
}),
registryDir
);

expect(existsSync(lockedRecordPath)).toBe(true);
expect(existsSync(currentRecordPath)).toBe(true);
} finally {
vi.doUnmock('node:fs/promises');
vi.resetModules();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate the test file and inspect relevant sections
FILE="code/core/src/core-server/utils/runtime-instance-registry.test.ts"
echo "=== File exists? ==="
ls -l "$FILE"

echo "=== Relevant section (approx lines 330-520) ==="
nl -ba "$FILE" | sed -n '330,520p'

# 2) Search for vi.mock/vi.doMock/vi.spyOn/vi.resetModules in that file
echo "=== Mocking usage in file ==="
rg -n "vi\.(mock|doMock|resetModules|unmock|useFakeTimers|setSystemTime|mocked|spyOn)" "$FILE"

# 3) Read spy-mocking rules
RULES=".cursor/rules/spy-mocking.mdc"
echo "=== Reading spy-mocking rules ($RULES) ==="
ls -l "$RULES"
nl -ba "$RULES" | head -n 200

Repository: storybookjs/storybook

Length of output: 312


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="code/core/src/core-server/utils/runtime-instance-registry.test.ts"
RULES=".cursor/rules/spy-mocking.mdc"

echo "=== File exists? ==="
ls -l "$FILE"

echo "=== Relevant section (approx lines 330-520) ==="
# Print with line numbers using awk (no nl in sandbox)
awk 'NR>=330 && NR<=520 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo "=== Mocking usage in file ==="
rg -n "vi\.(mock|doMock|resetModules|unmock|useFakeTimers|setSystemTime|mocked|spyOn)" "$FILE" || true

echo "=== Reading spy-mocking rules ($RULES) ==="
ls -l "$RULES"
awk 'NR<=220 {printf "%6d  %s\n", NR, $0}' "$RULES"

Repository: storybookjs/storybook

Length of output: 10201


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="code/core/src/core-server/utils/runtime-instance-registry.test.ts"

echo "=== vi.mock / vi.doMock occurrences in whole file ==="
rg -n "vi\.(mock|doMock)" "$FILE" || true

echo "=== Other module-reset/dynamic import related patterns ==="
rg -n "vi\.resetModules\(|vi\.import\(\'.*runtime-instance-registry\.ts\'\)|vi\.doUnmock\(" "$FILE" || true

echo "=== Is there any top-level vi.mock (outside tests)? ==="
# Best-effort: print first 120 lines where top-level declarations would usually be.
awk 'NR<=160 {print NR ":" $0}' "$FILE"

Repository: storybookjs/storybook

Length of output: 4959


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="code/core/src/core-server/utils/runtime-instance-registry.test.ts"

echo "=== beforeEach / afterEach occurrences ==="
rg -n "beforeEach\(|afterEach\(" "$FILE" || true

echo "=== Mocks-related occurrences ==="
rg -n "vi\.(mocked|spyOn|mockImplementation|mockReturnValue|useFakeTimers|setSystemTime|resetModules|doMock|doUnmock)" "$FILE" || true

echo "=== Context around killSpy test (360-410) ==="
awk 'NR>=350 && NR<=410 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo "=== Context around node:fs/promises mock tests (400-510) ==="
awk 'NR>=395 && NR<=510 {printf "%6d  %s\n", NR, $0}' "$FILE"

Repository: storybookjs/storybook

Length of output: 8033


Hoist the node:fs/promises mock to top-level and use the repo’s vi.mock(..., { spy: true }) pattern
runtime-instance-registry.test.ts mocks node:fs/promises inline via vi.resetModules() + vi.doMock() (lines 412-446 and 463-496), which breaks the spy-mocking.mdc rules (top-level mocks, spy: true, configure behavior in beforeEach, and access via vi.mocked() instead of dynamic re-imports).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/core-server/utils/runtime-instance-registry.test.ts` around
lines 374 - 445, The test is dynamically mocking 'node:fs/promises' inline with
vi.resetModules()/vi.doMock() and re-imports (creating flaky, non-top-level
mocks); instead hoist the mock to the top of runtime-instance-registry.test.ts
using vi.mock('node:fs/promises', () => ({ ...actual... }), { spy: true }),
remove vi.resetModules()/vi.doMock()/vi.doUnmock()/dynamic imports, and move
per-test behavior into beforeEach/afterEach where you call
vi.mocked(fsPromises).rm.mockImplementationOnce(...) or similar to throw EBUSY
for the lockedRecordPath; update references to use the normal static import
names (and the existing createRuntimeInstanceRecord/writeRuntimeInstanceRecord
symbols) so tests no longer re-import modules under mocked fs.

@kasperpeulen
kasperpeulen merged commit ad7718c into next Jun 2, 2026
144 of 148 checks passed
@kasperpeulen
kasperpeulen deleted the kasper/runtime-registry-cleanup branch June 2, 2026 08:53
This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:normal Run our default set of CI jobs (choose this for most PRs). maintenance User-facing maintenance tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tracking: make runtime instance registry cleanup resilient

2 participants