Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/host/app/routes/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ export default class CommandRunnerRoute extends Route<CommandRunnerModel> {
// 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()) {
Expand Down
9 changes: 9 additions & 0 deletions packages/host/app/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@ export default class RenderRoute extends Route<Model> {
// 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()) {
Expand Down
36 changes: 36 additions & 0 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
196 changes: 196 additions & 0 deletions packages/host/tests/acceptance/prerender-persistence-block-test.gts
Original file line number Diff line number Diff line change
@@ -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<typeof this> {
<template>
<h2 data-test-pet><@fields.name /></h2>
</template>
};
}

class SavePetResult extends CardDef {
static displayName = 'SavePetResult';
@field savedId = contains(StringField);
static isolated = class Isolated extends Component<typeof this> {
<template>
<span data-test-saved-pet-id><@fields.savedId /></span>
</template>
};
}

// 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<undefined, typeof SavePetResult> {
static displayName = 'SavePetCommand';
async getInputType() {
return undefined;
}
protected async run(): Promise<SavePetResult> {
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 || '<empty>'}`,
);
// 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',
);
}
});
});
36 changes: 36 additions & 0 deletions packages/host/tests/integration/store-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading