Block conflicting site operations - #4406
Conversation
📊 Performance Test ResultsComparing f3daa1b vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
…le-stopping # Conflicts: # apps/ui/src/components/site-dropdown/main-view.tsx # apps/ui/src/components/site-list/index.tsx # apps/ui/src/components/site-overview-view/index.tsx
Removes the two SITE_EVENTS.UPDATED emits withSiteLock fires on acquire and release, and widens the metrics artifact glob so a failure uploads Playwright's error-context.md. The emits are the only behavioural difference this branch makes to the desktop: they triple the running-state broadcasts per site start/stop, because _events recomputes `running` for each one and the desktop treats that as authoritative. site-startup fails on iteration 3 of 5 every run, which fits accumulation. They are also the signal the UI uses to show "Exporting…", so if this turns the job green they get reinstated as a lease-specific event that carries no running verdict. Both changes revert before merge.
… into guard-site-start-while-stopping
Restores the UI signal the diagnostic commit removed, as its own event kind. Reusing SITE_EVENTS.UPDATED made every lease change assert whether the site was running. Both consumers treat that as authoritative: the main process overwrites SiteServer.details.running and runs the start/stop side effects off the transition, and the renderer merges the flag and clears its per-site loading state. So claiming a lease mid-stop reported the site as still running and cleared the loading flag before the stop had begun, which is what failed the site-startup performance metric on the third cycle. SITE_EVENTS.OPERATIONS_CHANGED carries only the lease set. Both consumers apply `operations` and leave running and loading alone.
fredrikekelund
left a comment
There was a problem hiding this comment.
Do I understand it correctly that we aren't really distinguishing between different types of locks? You say as much in your description: for example, I cannot start or stop a site while exporting it.
I can see how pairing all the different possible locks could easily become arduous, but not permitting users to start the site while an export is happening seems like a functional regression.
Maybe we should scale this back and lock only site start and stop operations? We know that users are running into problems with those specific operations (judging by the related issues in STU-2220), and it makes perfect sense that those operations should not conflict. Maybe add site deletion to that list, too.
It's not immediately clear to me that exporting/importing, pulling/pushing, and listing sites would cause similar conflicts. It's true that WP-CLI operations execute in-process for Playground sites, so stopping the site in the midst of an export could indeed mess with the export operation, but I still hesitate to introduce such a sweeping change to prevent that specific issue…
| // Must override what `...site` carries, not just add to it: the stored | ||
| // array can hold leases from crashed processes, and reporting those | ||
| // would leave clients disabling the site forever. `undefined` drops the | ||
| // key on serialization, so an idle site reports no `operations` at all. |
There was a problem hiding this comment.
- There's something about how Claude and Codex phrase comments that oftentimes just doesn't make things more understandable… I understand the point here after reading it a few times, but it's almost like this comment mystifies what's actually quite straightforward if I just read the code and see that we filter the
operationsarray throughgetLiveSiteOperations()and returnundefinediflength === 0. - "reporting those would leave clients disabling the site forever" -> is this actually true? Is it Studio Code that reads the list of sites in this way?
There was a problem hiding this comment.
We're mixing terms between "lock", "lease", and "operations" here.
I would argue that "lease" is the most ambiguous term. It'd be nice to standardize the other two.
Agree we can drop export, it's the weakest case in the set, and blocking a start during one is a poor trade. It was in there because For the rest, I did explore action pairings, but I'd rather start strict than risk broken states. With the Agentic UI the agent and the user are both acting on a site, so concurrency goes from rare to routine. Keep start/stop/delete/import/pull/duplicate/settings strict and relax specific pairs as we prove them safe? PS: just to clarify from the first comment the |
…le-stopping # Conflicts: # apps/ui/src/components/site-list/index.tsx # apps/ui/src/components/site-overview-view/index.test.tsx # apps/ui/src/components/site-overview-view/index.tsx # apps/ui/src/hooks/use-site-management-actions.ts
…le-stopping # Conflicts: # apps/cli/commands/export.ts # apps/cli/commands/import.ts
…le-stopping # Conflicts: # apps/ui/src/components/site-dropdown/main-view.tsx
Let's think about this theoretically for a moment before landing this… We might not revisit this so soon after landing this PR, so better to go over the cases now. Start, stop, and delete make sense. Import and pull… I'd want to lock them against secondary import and pull operations (which would clearly break the site). Deleting a site while an import/pull is happening would obviously also mess things up. Stopping a site while import/pull is happening is harmless for native PHP but potentially harmful for Playground (because it might kill an ongoing WP-CLI command that's executing in-process). The thing that worries me the most here is that imports and pulls, especially, are slow. Holding a lock for 10 minutes seems very risky to me from a UX standpoint. Duplicate seems harmless to me. It's just a copy+start operation. Am I missing something? The one potential risk is if a site is duplicated while an import/pull is happening, which could potentially leave the new site in a bad state. Settings also seems mostly harmless because the I'd be fine shipping start/stop/delete/settings, but I think we should clarify the need during import/pull/duplicate before landing this PR. Happy to jump on a huddle and discuss 👍 |
Let's think the other way around. If a user is doing an import/pull, what's the use case for working on the site while it's ongoing? Starting and stopping can have side effects, and the proposal is broad precisely because those are hard to enumerate — so we "freeze" the site as much as we can. The user or AI agent can still change files and records directly; I just want to prevent the invisible side-effects caused by Studio itself. As a user I'd rather wait 20–30 min for a pull than have a broken site for reasons I don't understand.
Yes, that's the case I had in mind. It seems odd to allow a duplicate while another operation is adding or removing files. Worth noting it's free either way: no CLI command performs duplication, so it never holds anything — it's only the UI disabling the button.
My motivation was the restart. Every setting except
I think we should land this with start/stop/delete/settings (+duplicate, which costs nothing) and pull/import. In a follow-up we can explore not freezing the site during the remote part of the operation, but that needs care: if the lock is only taken for the import phase, a busy site at that moment would fail the pull and throw away 30 minutes of work. That seems complex enough to deserve its own PR — so let's start conservative and reduce the lock later. Push and export I'll drop — neither ever stops the site, which is exactly why blocking a start during them was the regression you flagged. |
fredrikekelund
left a comment
There was a problem hiding this comment.
@bcotrim and I huddled to continue our discussion in this PR. We concluded that a reasonable way of shipping this was to remove the locking functionality for pull and import operations (as those locks would be held for so long that it could have a detrimental UX impact).
I've tested the changes in this PR briefly and can confirm they work. They also interact nicely with the agentic UI 👍
Approving this with some suggestions (primarily around potential code simplifications in the UI) ahead of Bernardo's changes that will remove locking functionality for pulling and import commands.
| site, | ||
| isStarting, | ||
| isStopping, | ||
| useSiteOperation( site ) |
There was a problem hiding this comment.
Calling a React hook as an argument instead of assigning it to a variable has a weird smell, even if it doesn't matter from a functional perspective
| const isLocalTransitioning = useIsSiteBusy( site ); | ||
| const operation = useSiteOperation( site ); |
There was a problem hiding this comment.
Two things:
- Doesn't
useSiteOperationtell us the exact same thing asuseIsSiteBusy? - If they do tell us different things, can we rename
isLocalTransitioning? MaybeisCliOperationInProgressinstead
| export function getSiteStatusName( { | ||
| running, | ||
| starting, | ||
| stopping, | ||
| operation, | ||
| }: { | ||
| running: boolean; | ||
| starting: boolean; | ||
| stopping: boolean; | ||
| operation: SiteOperationKind | null; | ||
| } ): string { |
There was a problem hiding this comment.
Similarly, doesn't operation already tell us if the site is stopping or starting?
| function LocalServerControl( { | ||
| running, | ||
| starting, | ||
| stopping, | ||
| operation, | ||
| disabled, | ||
| onStart, | ||
| onStop, | ||
| }: { | ||
| running: boolean; | ||
| starting: boolean; | ||
| stopping: boolean; | ||
| // A CLI operation (an agent export, another window's import). Blocks the toggle | ||
| // and names itself in the tooltip, so a dead control explains why. | ||
| operation: SiteOperationKind | null; | ||
| disabled: boolean; | ||
| onStart: () => void; | ||
| onStop: () => void; | ||
| } ) { |
There was a problem hiding this comment.
Doesn't operation already tell us if a site is stopping or starting? Do we need the starting and stopping props also?
| const startSite = useStartSite(); | ||
| const stopSite = useStopSite(); | ||
| const { status } = deriveSiteStatus( site, isStarting, isStopping ); | ||
| const busy = isStarting || isStopping; | ||
| const statusName = | ||
| status === 'running' | ||
| ? __( 'Running' ) | ||
| : status === 'transitioning' | ||
| ? isStopping | ||
| ? __( 'Stopping' ) | ||
| : __( 'Starting' ) | ||
| : __( 'Stopped' ); | ||
| const busy = useIsSiteBusy( site ); | ||
| const operation = useSiteOperation( site ); | ||
| const { status } = deriveSiteStatus( site, isStarting, isStopping, operation ); | ||
| // The recorded operation wins: it names work this window didn't start (an | ||
| // agent import, another Studio window) that local start/stop state can't see. | ||
| const statusName = getSiteStatusName( { | ||
| running: site.running, | ||
| starting: isStarting, | ||
| stopping: isStopping, | ||
| operation, | ||
| } ); |
There was a problem hiding this comment.
Same theme: doesn't operation already tell us if the site is starting or stopping?
| // Identifies which entry to clear on release, so a process can't drop a | ||
| // different operation that happens to share its PID. | ||
| id: z.string(), |
There was a problem hiding this comment.
Just thought I'd note that this appears redundant for now. I'm fine with keeping it in, but for now, a single process always holds a single lock, AFAICT.
Related issues
How AI was used in this PR
Claude Code handled the implementation. I designed the solution, reviewed and iterated over the code, and manually tested.
Proposed Changes
Two Studio operations on the same site could run at once and corrupt each other. Starting a site while it was stopping left it down while the UI claimed it started, then looped retrying. Because the agent calls the CLI directly, guarding this in the UI alone wouldn't hold.
The CLI now records the in-flight operation on the site and refuses a conflicting one with a readable error — so the guard applies no matter who started the work: you, the agent, a terminal, or a second window. One at a time: start, stop, delete and settings changes each own the site's server process, and duplicate copies the whole site directory. The operation belongs to the process holding it, so a crash or force quit releases it and no site can be left stuck.
Sync is deliberately out of scope: export and push never stop the server, so blocking a start during one would be a regression, and import and pull hold the site far too long to freeze it wholesale. Narrowing those to just their local write window is a follow-up.
The operation reaches the UI as its own kind of event, deliberately not the general "site updated" one — that asserts whether the site is running, and consumers treat it as authoritative. The UI disables the actions it blocks and names what's running ("Saving settings…") instead of leaving controls that look live but swallow the click. Progress survives navigating away and back.
Testing Instructions
studio config set --wp 6.4 --path <site>— it runswp core update, so it holds the site for a while.kill -9the CLI mid-run. The site must not stay locked.studio site listis never blocked, and that export / import / pull / push still run while the site is busy.Verified in light and dark mode.
Pre-merge Checklist
🤖 Generated with Claude Code