diff --git a/apps/studio/src/lib/site-list-cache.ts b/apps/studio/src/lib/site-list-cache.ts new file mode 100644 index 0000000000..c5f19a4cfa --- /dev/null +++ b/apps/studio/src/lib/site-list-cache.ts @@ -0,0 +1,97 @@ +import { SITE_EVENTS, type SiteEvent, type SiteListItem } from '@studio/common/lib/cli-events'; +import { listSites } from '@studio/common/sites/list'; +import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; + +// Changes no event reports (e.g. a crashed daemon) show up within this long, the +// interval the renderer polls the site list on. +export const SITE_LIST_MAX_AGE_MS = 2 * 60 * 1000; + +/** + * The CLI's site list, kept current by the `_events` stream so that reading it doesn't start a CLI + * process each time. It's only used while the stream runs, and `drop()` discards it, so the next + * read asks the CLI again. + */ +export function createSiteListCache( fetchSites: () => Promise< SiteListItem[] >, now = Date.now ) { + let live = false; + let sites: Map< string, SiteListItem > | undefined; + let fetchedAt = 0; + // The fetch that will become the cache, with the events that arrive while it runs. + let pending: { promise: Promise< SiteListItem[] >; events: SiteEvent[] } | undefined; + + function drop() { + sites = undefined; + pending = undefined; + } + + // Returns false when the event doesn't carry what's needed to apply it. + function apply( list: Map< string, SiteListItem >, event: SiteEvent ): boolean { + if ( event.event === SITE_EVENTS.DELETED ) { + list.delete( event.siteId ); + return true; + } + if ( ! event.site ) { + return false; + } + // The CLI reports `running` the way `site list` does. + list.set( event.siteId, { ...event.site, running: event.running } ); + return true; + } + + function fetchAndKeep(): Promise< SiteListItem[] > { + const startedAt = now(); + const fetch = { + // With the events that arrived meanwhile, which a reader that joined the fetch after an + // event expects to see. + promise: fetchSites().then( ( list ) => { + const kept = new Map( list.map( ( site ) => [ site.id, site ] ) ); + if ( ! fetch.events.every( ( event ) => apply( kept, event ) ) ) { + return list; + } + if ( pending === fetch && live ) { + pending = undefined; + sites = kept; + fetchedAt = startedAt; + } + return [ ...kept.values() ]; + } ), + events: [] as SiteEvent[], + }; + fetch.promise.catch( () => { + if ( pending === fetch ) { + pending = undefined; + } + } ); + pending = fetch; + return fetch.promise; + } + + return { + /** Whether the `_events` stream is running, so that its events keep the cache current. */ + setLive( value: boolean ) { + live = value; + drop(); + }, + + drop, + + applyEvent( event: SiteEvent ) { + pending?.events.push( event ); + if ( sites && ! apply( sites, event ) ) { + drop(); + } + }, + + read(): Promise< SiteListItem[] > { + if ( ! live ) { + return fetchSites(); + } + if ( sites && now() - fetchedAt < SITE_LIST_MAX_AGE_MS ) { + return Promise.resolve( [ ...sites.values() ] ); + } + return pending?.promise ?? fetchAndKeep(); + }, + }; +} + +// Kept current by the CLI events subscriber. +export const siteListCache = createSiteListCache( () => listSites( executeCliCommand ) ); diff --git a/apps/studio/src/lib/sites.ts b/apps/studio/src/lib/sites.ts index 95cb687b26..4bd9bb7972 100644 --- a/apps/studio/src/lib/sites.ts +++ b/apps/studio/src/lib/sites.ts @@ -1,8 +1,8 @@ import fs from 'fs'; import * as Sentry from '@sentry/electron/main'; import { - listSitesWithMetadata, updateSiteMetadata, + withSiteMetadata, type SiteMetadata, } from '@studio/common/sites/metadata'; import { @@ -10,6 +10,7 @@ import { WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT, } from 'src/constants'; import { isInErrorRecovery, stopErrorRecovery } from 'src/lib/php-error-recovery'; +import { siteListCache } from 'src/lib/site-list-cache'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; import { getSiteThumbnailPath } from 'src/storage/paths'; import type { SiteListItem } from '@studio/common/lib/cli-events'; @@ -22,7 +23,7 @@ export type Site = SiteListItem & SiteMetadata; /** The user's sites as the CLI reports them, with Studio's own metadata merged in. */ export async function getSites(): Promise< Site[] > { - const sites = await listSitesWithMetadata( executeCliCommand ); + const sites = await withSiteMetadata( siteListCache.read() ); // A site serving a PHP-error page counts as running even though the CLI reports it stopped. return sites.map( ( site ) => isInErrorRecovery( site.id ) diff --git a/apps/studio/src/lib/tests/site-list-cache.test.ts b/apps/studio/src/lib/tests/site-list-cache.test.ts new file mode 100644 index 0000000000..665e155cfb --- /dev/null +++ b/apps/studio/src/lib/tests/site-list-cache.test.ts @@ -0,0 +1,129 @@ +import { SITE_EVENTS, type SiteEvent, type SiteListItem } from '@studio/common/lib/cli-events'; +import { vi } from 'vitest'; +import { createSiteListCache, SITE_LIST_MAX_AGE_MS } from 'src/lib/site-list-cache'; + +vi.mock( 'src/modules/cli/lib/execute-command' ); + +function site( id: string, running = false ): SiteListItem { + return { + id, + name: id, + path: `/sites/${ id }`, + port: 8881, + url: 'http://localhost:8881', + phpVersion: '8.3', + running, + }; +} + +function event( type: SITE_EVENTS, siteId: string, running = false, name = siteId ): SiteEvent { + const { running: _, ...details } = site( siteId ); + return { event: type, siteId, running, site: { ...details, name } }; +} + +function setup( initial: SiteListItem[] = [ site( 'a' ) ] ) { + let clock = 0; + const fetchSites = vi.fn( async () => initial ); + const cache = createSiteListCache( fetchSites, () => clock ); + cache.setLive( true ); + return { cache, fetchSites, advance: ( ms: number ) => ( clock += ms ) }; +} + +describe( 'site list cache', () => { + it( 'asks the CLI once while the events stream runs', async () => { + const { cache, fetchSites } = setup(); + await cache.read(); + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'asks the CLI every time without the events stream', async () => { + const { cache, fetchSites } = setup(); + cache.setLive( false ); + await cache.read(); + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'shares one CLI call between reads that overlap', async () => { + const { cache, fetchSites } = setup(); + await Promise.all( [ cache.read(), cache.read() ] ); + expect( fetchSites ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'applies site events', async () => { + const { cache, fetchSites } = setup(); + await cache.read(); + cache.applyEvent( event( SITE_EVENTS.UPDATED, 'a', true, 'renamed' ) ); + cache.applyEvent( event( SITE_EVENTS.CREATED, 'b' ) ); + expect( await cache.read() ).toEqual( [ + { ...site( 'a', true ), name: 'renamed' }, + site( 'b' ), + ] ); + + cache.applyEvent( { event: SITE_EVENTS.DELETED, siteId: 'a', running: false } ); + expect( await cache.read() ).toEqual( [ site( 'b' ) ] ); + expect( fetchSites ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'applies operation events to a site it has yet to list', async () => { + const { cache, fetchSites } = setup(); + await cache.read(); + cache.applyEvent( event( SITE_EVENTS.OPERATIONS_CHANGED, 'b' ) ); + expect( await cache.read() ).toEqual( [ site( 'a' ), site( 'b' ) ] ); + expect( fetchSites ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'applies the events that arrive while the CLI is answering', async () => { + const { cache, fetchSites } = setup(); + const reading = cache.read(); + cache.applyEvent( event( SITE_EVENTS.UPDATED, 'a', true ) ); + // A reader that heard of the event joins the answer that predates it. + const joining = cache.read(); + expect( await reading ).toEqual( [ site( 'a', true ) ] ); + expect( await joining ).toEqual( [ site( 'a', true ) ] ); + expect( await cache.read() ).toEqual( [ site( 'a', true ) ] ); + expect( fetchSites ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'asks the CLI again after an event it cannot apply', async () => { + const { cache, fetchSites } = setup(); + await cache.read(); + cache.applyEvent( { event: SITE_EVENTS.UPDATED, siteId: 'a', running: true } ); + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'asks the CLI again once dropped', async () => { + const { cache, fetchSites } = setup(); + await cache.read(); + cache.drop(); + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'keeps no answer that was dropped while the CLI was answering', async () => { + const { cache, fetchSites } = setup(); + const reading = cache.read(); + cache.drop(); + await reading; + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'asks the CLI again once the list is old', async () => { + const { cache, fetchSites, advance } = setup(); + await cache.read(); + advance( SITE_LIST_MAX_AGE_MS ); + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'asks the CLI again after a failed answer', async () => { + const { cache, fetchSites } = setup(); + fetchSites.mockRejectedValueOnce( new Error( 'CLI failed' ) ); + await expect( cache.read() ).rejects.toThrow( 'CLI failed' ); + await cache.read(); + expect( fetchSites ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index bcc7125b55..d6a0d534d6 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -12,11 +12,12 @@ import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { trackSyncActivity } from 'src/lib/active-sync-operations'; import { captureSiteThumbnail } from 'src/lib/capture-site-thumbnail'; import { isInErrorRecovery } from 'src/lib/php-error-recovery'; +import { siteListCache } from 'src/lib/site-list-cache'; import { forgetSite, getSites } from 'src/lib/sites'; -import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; +import { executeCliCommand, onCliCommandFinished } from 'src/modules/cli/lib/execute-command'; // The last running state of each site, so a site's thumbnail is captured when it starts only. -// Seeded from the site list when the subscriber starts; a site missing from it is new, so stopped. +// Seeded from the site list once the events stream listens; a site missing from it is new, so stopped. const lastRunning = new Map< string, boolean >(); let seeded: Promise< void > = Promise.resolve(); @@ -54,18 +55,27 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > } ); let subscriber: ReturnType< typeof executeCliCommand > | null = null; +let watchingCommands = false; + +// A command's changes can reach the events stream after it exits, so a site list read after a +// command asks the CLI again. +function dropSiteListAfterCommands() { + if ( watchingCommands ) { + return; + } + watchingCommands = true; + onCliCommandFinished( ( [ command, subcommand ] ) => { + if ( command !== '_events' && ! ( command === 'site' && subcommand === 'list' ) ) { + siteListCache.drop(); + } + } ); +} export async function startCliEventsSubscriber(): Promise< void > { if ( subscriber ) { return; } - seeded = getSites() - .then( ( sites ) => { - for ( const site of sites ) { - lastRunning.set( site.id, site.running ); - } - } ) - .catch( () => undefined ); + dropSiteListAfterCommands(); return new Promise( ( resolve, reject ) => { if ( subscriber ) { @@ -82,7 +92,21 @@ export async function startCliEventsSubscriber(): Promise< void > { resolve(); } ); + let live = false; eventEmitter.on( 'data', ( { data } ) => { + // Its first message comes once it listens for changes, so its events from then on are complete. + if ( ! live ) { + live = true; + siteListCache.setLive( true ); + seeded = getSites() + .then( ( sites ) => { + for ( const site of sites ) { + lastRunning.set( site.id, site.running ); + } + } ) + .catch( () => undefined ); + } + const authParsed = cliAuthEventSchema.safeParse( data ); if ( authParsed.success ) { const { event, token } = authParsed.data.value; @@ -113,16 +137,20 @@ export async function startCliEventsSubscriber(): Promise< void > { } const siteEvent = parsed.data.value; + // Before the renderer hears of the event, so the site list it then reads includes it. + siteListCache.applyEvent( siteEvent ); void handleSiteEvent( siteEvent ); } ); eventEmitter.on( 'error', ( { error } ) => { + siteListCache.setLive( false ); reject( error ); console.error( 'CLI events subscriber error:', error ); subscriber = null; } ); eventEmitter.on( 'failure', () => { + siteListCache.setLive( false ); console.warn( 'CLI events subscriber exited unexpectedly' ); subscriber = null; } ); @@ -130,6 +158,7 @@ export async function startCliEventsSubscriber(): Promise< void > { } export function stopCliEventsSubscriber(): void { + siteListCache.setLive( false ); if ( subscriber ) { const [ eventEmitter, childProcess ] = subscriber; eventEmitter.removeAllListeners(); diff --git a/apps/studio/src/modules/cli/lib/execute-command.ts b/apps/studio/src/modules/cli/lib/execute-command.ts index 64bb619583..0d0c77d9d3 100644 --- a/apps/studio/src/modules/cli/lib/execute-command.ts +++ b/apps/studio/src/modules/cli/lib/execute-command.ts @@ -28,8 +28,20 @@ function getRunner(): CliRunner { return runner; } -export const executeCliCommand = ( ( args, options ) => - getRunner().executeCliCommand( args, options ) ) as ExecuteCliCommand; +const finishedListeners = new Set< ( args: string[] ) => void >(); + +/** Call `listener` with each command's arguments when the command exits. */ +export function onCliCommandFinished( listener: ( args: string[] ) => void ): void { + finishedListeners.add( listener ); +} + +export const executeCliCommand = ( ( args, options ) => { + const command = getRunner().executeCliCommand( args, options ); + const notify = () => finishedListeners.forEach( ( listener ) => listener( args ) ); + command[ 0 ].on( 'success', notify ); + command[ 0 ].on( 'failure', notify ); + return command; +} ) as ExecuteCliCommand; // Called on quit before the quit flow starts its own commands, which must not be stopped. Only asks, // so a sync past the point where stopping is safe finishes (see `exitOnCancel` in the CLI). diff --git a/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts index 85681a3392..114be8a2b5 100644 --- a/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts +++ b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts @@ -4,18 +4,21 @@ import { vi } from 'vitest'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { captureSiteThumbnail } from 'src/lib/capture-site-thumbnail'; import { isInErrorRecovery } from 'src/lib/php-error-recovery'; +import { siteListCache } from 'src/lib/site-list-cache'; import { forgetSite, getSites } from 'src/lib/sites'; import { startCliEventsSubscriber } from 'src/modules/cli/lib/cli-events-subscriber'; -import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; +import { executeCliCommand, onCliCommandFinished } from 'src/modules/cli/lib/execute-command'; vi.mock( 'src/ipc-utils' ); vi.mock( 'src/lib/active-sync-operations' ); vi.mock( 'src/lib/capture-site-thumbnail' ); vi.mock( 'src/lib/php-error-recovery' ); +vi.mock( 'src/lib/site-list-cache' ); vi.mock( 'src/lib/sites' ); vi.mock( 'src/modules/cli/lib/execute-command' ); const cliEvents = new EventEmitter(); +let commandFinished: ( args: string[] ) => void; function siteDetails( id: string ) { return { @@ -41,6 +44,7 @@ beforeAll( async () => { } ); vi.mocked( getSites ).mockResolvedValue( [ { id: 'site-a', running: true } ] as never ); await startCliEventsSubscriber(); + commandFinished = vi.mocked( onCliCommandFinished ).mock.calls[ 0 ][ 0 ]; } ); beforeEach( () => { @@ -49,6 +53,17 @@ beforeEach( () => { } ); describe( 'CLI events subscriber', () => { + it( 'keeps the site list current from its events once the stream listens', async () => { + // First, so that the stream hasn't spoken yet. + cliEvents.emit( 'data', { data: { action: 'startDaemon', status: 'inprogress' } } ); + expect( siteListCache.setLive ).toHaveBeenCalledWith( true ); + + await emitSiteEvent( SITE_EVENTS.UPDATED, 'site-a', true ); + expect( siteListCache.applyEvent ).toHaveBeenCalledWith( + expect.objectContaining( { siteId: 'site-a', running: true } ) + ); + } ); + it( "captures a site's thumbnail when it starts, starting from the site list", async () => { await emitSiteEvent( SITE_EVENTS.UPDATED, 'site-a', true ); expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( @@ -84,6 +99,15 @@ describe( 'CLI events subscriber', () => { ); } ); + it( 'drops the site list after a command that may change sites', () => { + commandFinished( [ 'site', 'list', '--format', 'json' ] ); + commandFinished( [ '_events' ] ); + expect( siteListCache.drop ).not.toHaveBeenCalled(); + + commandFinished( [ 'site', 'start', '--path', '/sites/site-a' ] ); + expect( siteListCache.drop ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'forgets a deleted site', async () => { await emitSiteEvent( SITE_EVENTS.DELETED, 'site-c', false ); diff --git a/apps/studio/src/tests/index.test.ts b/apps/studio/src/tests/index.test.ts index c4f987a2d7..0eea5a8005 100644 --- a/apps/studio/src/tests/index.test.ts +++ b/apps/studio/src/tests/index.test.ts @@ -52,6 +52,7 @@ vi.mock( 'src/modules/cli/lib/execute-command', () => { return { executeCliCommand: vi.fn().mockReturnValue( [ mockEventEmitter, mockChildProcess ] ), getTracksOriginEnv: vi.fn( () => 'studio-ui:v1' ), + onCliCommandFinished: vi.fn(), stopCliCommands: vi.fn(), }; } ); diff --git a/packages/common/sites/metadata.ts b/packages/common/sites/metadata.ts index d04a20820b..0dd10deaed 100644 --- a/packages/common/sites/metadata.ts +++ b/packages/common/sites/metadata.ts @@ -42,10 +42,17 @@ export async function updateSiteMetadata( } /** The CLI's site list, with each site's Studio-owned metadata merged in. */ -export async function listSitesWithMetadata( +export function listSitesWithMetadata( execute: ExecuteCliCommand ): Promise< ( SiteListItem & SiteMetadata )[] > { - const [ sites, metadata ] = await Promise.all( [ listSites( execute ), readSiteMetadata() ] ); + return withSiteMetadata( listSites( execute ) ); +} + +/** Merge each site's Studio-owned metadata into a site list. */ +export async function withSiteMetadata( + siteList: Promise< SiteListItem[] > +): Promise< ( SiteListItem & SiteMetadata )[] > { + const [ sites, metadata ] = await Promise.all( [ siteList, readSiteMetadata() ] ); return sites.map( ( site ) => { const { sortOrder, autoStart } = metadata[ site.id ] ?? {}; return { ...site, sortOrder, autoStart };