diff --git a/packages/host/app/routes/command-runner.ts b/packages/host/app/routes/command-runner.ts index abe986e6604..2710623d853 100644 --- a/packages/host/app/routes/command-runner.ts +++ b/packages/host/app/routes/command-runner.ts @@ -93,6 +93,24 @@ export default class CommandRunnerRoute extends Route { // tests also raise around in-browser index renders that run alongside an // interactive app whose saves must keep their indexed echo. (globalThis as any).__boxelHeadlessCommand = true; + // `__boxelPrerenderApp` marks the app as the dedicated prerender app for + // its whole lifetime, and blocks persistence on every store while it is + // raised (see `renderContextBlocksPersistence`). This route is the one + // exception: a command is expected to write, and its writes are + // deadlock-safe through the deferred indexing `__boxelHeadlessCommand` + // asks for. So drop the block here rather than leave it standing. + // + // It has to be dropped rather than merely never raised: a pool tab that + // has served a card render carries the flag, and the pool can retag that + // tab from its realm affinity onto the user affinity a command runs on. + // Under the block a command's save resolves to an instance with no id, + // which every caller reads as success. + // + // Deliberately not restored on teardown. The render route raises the flag + // itself in `beforeModel`, and that hook runs before the exit hooks of + // the route being left — so a restore of a never-raised value would lower + // the block for the whole of the next render's model hook. + (globalThis as any).__boxelPrerenderApp = undefined; registerDestructor(this, () => { (globalThis as any).__boxelHeadlessCommand = undefined; if (isTesting()) { diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index e8b8dd8f7a9..09d627f2326 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -242,6 +242,15 @@ export default class RenderRoute extends Route { // activate() doesn't run early enough for this to be set before the model() // hook is run (globalThis as any).__boxelRenderContext = true; + // A render is never a headless command. The command route raises + // `__boxelHeadlessCommand` and drops it in its own teardown, but a + // transition runs the entering route's model hooks before the departing + // route's exit hooks — so a tab arriving here from the command route + // still carries the flag through this render's model hook. Clearing it + // keeps a render's writes from being marked for deferred indexing, and + // keeps a card that writes while rendering on the store's drop path + // rather than its report-a-stuck-command path. + (globalThis as any).__boxelHeadlessCommand = undefined; this.#registerGlobalsDestructor(); this.#authGuard.register(); if (!isTesting()) { diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 4b3cd99b53e..c676dc5fa50 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -339,6 +339,11 @@ export default class StoreService extends Service implements StoreInterface { // store on __boxelRenderContext alone breaks it: card-prerender sets that // global around every test-realm index render, silently dropping app saves // that coincide with one. + // + // The command-runner route is the one place in the prerender app that + // drops `__boxelPrerenderApp`, because a command is expected to write and + // its writes index deferred rather than waiting on the worker the tab is + // holding. if ((globalThis as any).__boxelPrerenderApp) { return true; } @@ -827,6 +832,33 @@ export default class StoreService extends Service implements StoreInterface { } as CardResourceMeta; } + // A headless command running while the prerender app's persistence block + // is still raised is an impossible state, and the only one this path + // reports rather than absorbs. The command route drops the block on entry + // precisely so a command's writes can land; with the block still up the + // save resolves to an instance carrying no id, `SaveCardTool` returns it + // as saved, and every caller downstream — `boxel run-command` included — + // reads a card that does not exist as a success. `create` already throws + // on the same state. + // + // A card render is deliberately NOT an error. The prerenderer is not an + // avenue for mutations, and a card whose template or computed writes to + // the store is doing what it was designed to do — it just cannot have + // that write here, because it would aim at a realm whose sole indexing + // worker this render is occupying. Dropping the write renders the card; + // throwing would fail the render and index the card as an error. + if ( + !opts?.doNotPersist && + (globalThis as any).__boxelPrerenderApp && + (globalThis as any).__boxelHeadlessCommand + ) { + throw new Error( + `cannot persist instance ${ + instance.id ?? instance[localIdSymbol] + }: a headless command is running with the prerender app's persistence block still raised`, + ); + } + let maybeOldInstance = instance.id ? this.store.getCard(instance.id) : undefined; @@ -2054,6 +2086,10 @@ export default class StoreService extends Service implements StoreInterface { // deliberately not part of the test — card-prerender sets it around index // renders that run alongside an interactive app, whose own query fields must // keep resolving through those windows. + // + // A command runs with `__boxelPrerenderApp` dropped, so its query fields do + // resolve eagerly — matching what a command gets on a tab that has never + // served a render. protected resolvesQueryFieldsEagerly(): boolean { if ((globalThis as any).__boxelPrerenderApp) { return false; diff --git a/packages/host/tests/acceptance/prerender-persistence-block-test.gts b/packages/host/tests/acceptance/prerender-persistence-block-test.gts new file mode 100644 index 00000000000..01f34be4bf0 --- /dev/null +++ b/packages/host/tests/acceptance/prerender-persistence-block-test.gts @@ -0,0 +1,196 @@ +import { visit, waitFor } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { Command, type RenderRouteOptions } from '@cardstack/runtime-common'; + +import SaveCardTool from '@cardstack/host/tools/save-card'; + +import { + capturePrerenderResult, + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, +} from '../helpers'; + +import { + CardDef, + Component, + contains, + field, + setupBaseRealm, + StringField, +} from '../helpers/base-realm'; + +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +// `__boxelPrerenderApp` marks the dedicated prerender app for its whole +// lifetime, and blocks persistence on every store in it: a write from a render +// would aim at a realm whose sole indexing worker that render is occupying. +// The command route is the one place that drops the block, because a command is +// expected to write and its writes index deferred. +// +// Dropping it matters — rather than simply never raising it — because a pool +// tab that has served a card render carries the flag, and the pool can retag +// that tab from its realm affinity onto the user affinity commands run on. The +// tab then enters the command route through an in-app transition, so the app +// keeps whatever globals the render left behind, and a command running under +// the block answers with a card that was never saved. +// +// Outside tests the render route raises the flag itself. Here it is raised by +// hand, because these tests also run an interactive app whose own saves the +// flag would swallow. +module('Acceptance | prerender | persistence block', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + + setupBaseRealm(hooks); + + const RENDER_OPTIONS_SEGMENT = encodeURIComponent( + JSON.stringify({ clearCache: true } as RenderRouteOptions), + ); + + // The prerender driver always hands the render route the card's `.json` file + // URL, so render against that rather than the extensionless id. + async function renderCard(id: string) { + await visit( + `/render/${encodeURIComponent( + `${id}.json`, + )}/0/${RENDER_OPTIONS_SEGMENT}/html/isolated/0`, + ); + return await capturePrerenderResult('textContent'); + } + + function runCommand(requestId: string, nonce: string, command: string) { + window.localStorage.setItem( + `boxel-command-request:${requestId}`, + JSON.stringify({ + command, + input: null, + nonce, + createdAt: Date.now(), + }), + ); + return visit(`/command-runner/${requestId}/${nonce}`); + } + + function raisePersistenceBlock() { + (globalThis as any).__boxelPrerenderApp = true; + } + + hooks.beforeEach(async function () { + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + static isolated = class Isolated extends Component { + + }; + } + + class SavePetResult extends CardDef { + static displayName = 'SavePetResult'; + @field savedId = contains(StringField); + static isolated = class Isolated extends Component { + + }; + } + + // Reports the id its save came back with, so a dropped write is + // distinguishable from a durable one: an instance handed back from a + // blocked store carries no id, which reads as success to every caller. + class SavePetCommand extends Command { + static displayName = 'SavePetCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise { + let saved = await new SaveCardTool(this.toolContext).execute({ + card: new Pet({ name: 'Ringo' }), + realm: testRealmURL, + }); + return new SavePetResult({ savedId: saved?.id ?? '' }); + } + } + + await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'pet.gts': { Pet }, + 'Pet/mango.json': new Pet({ name: 'Mango' }), + 'save-pet-command.gts': { + SavePetResult, + default: SavePetCommand, + }, + }, + }); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__boxelPrerenderApp; + }); + + test('entering the command route drops the block', async function (assert) { + raisePersistenceBlock(); + await runCommand( + 'prerender-persistence-block-drop', + '1', + `${testRealmURL}save-pet-command/default`, + ); + + assert.strictEqual( + (globalThis as any).__boxelPrerenderApp, + undefined, + 'the command route drops the persistence block on entry', + ); + }); + + test('a command saves a durable card on a tab that has served a card render', async function (assert) { + raisePersistenceBlock(); + let { value } = await renderCard(`${testRealmURL}Pet/mango`); + assert.true(value.includes('Mango'), 'the card rendered'); + + await runCommand( + 'prerender-persistence-block-save', + '1', + `${testRealmURL}save-pet-command/default`, + ); + await waitFor('[data-prerender][data-prerender-status="ready"]'); + + let savedId = + document.querySelector('[data-test-saved-pet-id]')?.textContent?.trim() ?? + ''; + assert.ok( + savedId.startsWith(testRealmURL), + `the save came back with a realm id: ${savedId || ''}`, + ); + // Read the realm's own source rather than the store, so a card that only + // ever existed in memory cannot satisfy this. Guarded on a non-empty id: + // a blocked save yields none, and the read would then fail on URL parsing + // instead of on the assertion above that explains why. + if (savedId) { + let source = await getService('card-service').getSource( + new URL(`${savedId}.json`), + ); + assert.strictEqual(source.status, 200, 'the saved card is durable'); + assert.true( + source.content.includes('Ringo'), + 'the durable document holds what the command wrote', + ); + } + }); +}); diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 00afd789a24..8361a54a4ae 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -607,6 +607,42 @@ module('Integration | Store', function (hooks) { } }); + test('a card render absorbs a blocked write, a headless command reports it', async function (assert) { + // `__boxelPrerenderApp` blocks every store's writes in the prerender app. + // A card render absorbs the block: the prerenderer is not an avenue for + // mutations, so a card that writes from a template or computed still + // renders, with the write dropped. + (globalThis as any).__boxelPrerenderApp = true; + try { + let rendered = new PersonDef({ name: 'Andrea' }); + assert.strictEqual( + await storeService.add(rendered), + rendered, + 'a render keeps the instance rather than failing', + ); + + // A command is the one caller whose write must land, so the block being + // up here means the command route did not drop it — and an instance + // with no id reads as a saved card to every caller. + (globalThis as any).__boxelHeadlessCommand = true; + await assert.rejects( + storeService.add(new PersonDef({ name: 'Van Gogh' })), + /persistence block still raised/, + 'a command reports the blocked write', + ); + + let ephemeral = new PersonDef({ name: 'Mango' }); + assert.strictEqual( + await storeService.add(ephemeral, { doNotPersist: true }), + ephemeral, + 'a memory-only add is served as asked', + ); + } finally { + delete (globalThis as any).__boxelHeadlessCommand; + delete (globalThis as any).__boxelPrerenderApp; + } + }); + test('restoring sessions from storage skips the re-walk when the session blob is unchanged', function (assert) { // `restoreSessionsFromStorage` is synchronous, so the walk-count delta // measured immediately around each call is exactly that call's work — other