Skip to content
Open
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
197 changes: 128 additions & 69 deletions packages/host/app/resources/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { parse } from 'date-fns';
import { restartableTask } from 'ember-concurrency';
import { keepLatestTask, restartableTask } from 'ember-concurrency';
import { Resource } from 'ember-modify-based-class-resource';

import {
Expand All @@ -20,6 +20,7 @@ import type CardService from '@cardstack/host/services/card-service';
import type { SaveType } from '@cardstack/host/services/card-service';

import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';
import type RealmService from '@cardstack/host/services/realm';
import type RecentFilesService from '@cardstack/host/services/recent-files-service';
import type StoreService from '@cardstack/host/services/store';

Expand Down Expand Up @@ -143,6 +144,7 @@ class _FileResource extends Resource<Args> {
@service declare private cardService: CardService;
@service declare private recentFilesService: RecentFilesService;
@service declare private operatorModeStateService: OperatorModeStateService;
@service declare private realm: RealmService;
@service declare private store: StoreService;

constructor(owner: Owner) {
Expand Down Expand Up @@ -178,6 +180,28 @@ class _FileResource extends Resource<Args> {
this._url = url;
this.onStateChange = onStateChange;
this.onRedirect = onRedirect;

// Subscribe to realm events BEFORE the first fetch so a 404 result
// (e.g. the AI assistant navigates code-submode to a file it just
// created, before realm indexing has caught up) can still be recovered
// when the realm subsequently broadcasts an `index/incremental` event
// for this URL. Without this, the success-branch `setSubscription`
// below at the end of `read` is never reached on the 404 path, leaving
// the resource permanently in `not-found` despite the realm having
// since delivered the file.
let realmId = this.realm.realmOf(rri(url));
if (realmId) {
this.setSubscription(realmId, this.onRealmInvalidation);
} else {
// No early subscription possible — the realm service hasn't yet
// discovered the realm that owns this URL. Recovery from an initial
// 404 then depends on the success-branch `setSubscription` inside
// `read`, which only fires if the fetch eventually succeeds.
log.debug(
`FileResource: no known realm for ${url} at modify-time; deferring subscription to read-success branch`,
);
}

this.read.perform();
}

Expand All @@ -198,7 +222,16 @@ class _FileResource extends Resource<Args> {
}
}

private read = restartableTask(async (opts?: { force?: boolean }) => {
// `keepLatestTask`, not `restartableTask`: when an invalidation event
// arrives while a read is in flight, we want the in-flight read to
// complete and the event-driven reload to run AFTER it. Restarting
// here would cancel the in-flight task, and the cancelled task's
// awaited fetch would later throw TaskCancelation at the `await` —
// catching that as a real fetch failure would overwrite the fresh
// task's `state: 'ready'` with `state: 'not-found'` whenever the
// cancelled response happened to land after the restart's response.
// Queuing the latest extra perform keeps state writes sequential.
private read = keepLatestTask(async (opts?: { force?: boolean }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep navigation reads from applying stale responses

When a second navigation calls modify() while a previous file fetch is still in flight, keepLatestTask lets the old fetch finish instead of canceling it. That stale response still calls updateState(), and because updateState() compares the old response URL to the now-current this._url, an old extensionless/canonicalizing request can invoke onRedirect() and replace the user's newer code path back to the stale file before the queued latest read runs. This regresses quick code-mode navigation that restartableTask previously protected; either capture/validate the requested URL before applying results or keep navigation reads cancelable while only queuing invalidation reloads.

Useful? React with 👍 / 👎.

let response;
try {
response = await this.network.authedFetch(this._url, {
Expand Down Expand Up @@ -285,86 +318,112 @@ class _FileResource extends Resource<Args> {
},
});

this.setSubscription(realmURL, (event: RealmEventContent) => {
if (
event.eventName !== 'index' ||
// we wait specifically for the index complete event ("incremental") so
// that the subsequent index read retrieves the latest contents of the file
event.indexType !== 'incremental' ||
!Array.isArray(event.invalidations)
) {
return;
}
this.setSubscription(realmURL, this.onRealmInvalidation);
});

let { invalidations } = event as { invalidations: string[] };
let normalizedURL = this.url.endsWith('.json')
? this.url.replace(/\.json$/, '')
: this.url;
private onRealmInvalidation = (event: RealmEventContent): void => {
if (
event.eventName !== 'index' ||
// we wait specifically for the index complete event ("incremental") so
// that the subsequent index read retrieves the latest contents of the file
event.indexType !== 'incremental' ||
!Array.isArray(event.invalidations)
) {
return;
}

if (invalidations.includes(normalizedURL)) {
realmEventsLogger.trace(
`file resource ${normalizedURL} processing invalidation`,
event,
);
let { invalidations } = event as { invalidations: string[] };
// Match invalidations against both the currently-requested URL
// (`this._url`, kept current by `modify`) and the URL the resource
// most recently loaded into `innerState`. Both are necessary
// because:
// - `innerState.url` may be a realm-canonicalized form of `_url`
// (e.g. `experiments/author` redirects to `experiments/author.gts`)
// and the realm emits invalidations for the canonical form.
// - During a transition (modify called with a new URL while
// innerState still holds a prior file), `innerState.url` is
// stale; only `_url` reflects what the caller is asking for —
// dropping the event here would orphan the new file.
let normalize = (raw: string) =>
raw.endsWith('.json') ? raw.replace(/\.json$/, '') : raw;
let candidates = new Set<string>([normalize(this._url)]);
if (this.innerState.state !== 'loading') {
candidates.add(normalize(this.innerState.url));
}
let normalizedURL = invalidations.find((inv) => candidates.has(inv));

let clientRequestId = event.clientRequestId;
let reloadFile = false;
if (normalizedURL) {
realmEventsLogger.trace(
`file resource ${normalizedURL} processing invalidation`,
event,
);

if (!clientRequestId || clientRequestId.startsWith('instance:')) {
reloadFile = true;
let clientRequestId = event.clientRequestId;
let reloadFile = false;

if (!clientRequestId || clientRequestId.startsWith('instance:')) {
reloadFile = true;
realmEventsLogger.debug(
`reloading file resource ${normalizedURL} because realm event has ${!clientRequestId ? 'no clientRequestId' : 'clientRequestId from instance editor'}`,
);
} else if (
clientRequestId.startsWith('editor:') ||
clientRequestId.startsWith('editor-with-instance:')
) {
if (this.cardService.clientRequestIds.has(clientRequestId)) {
realmEventsLogger.debug(
`reloading file resource ${normalizedURL} because realm event has ${!clientRequestId ? 'no clientRequestId' : 'clientRequestId from instance editor'}`,
`ignoring because request id is contained in known clientRequestIds`,
event.clientRequestId,
);
} else if (
clientRequestId.startsWith('editor:') ||
clientRequestId.startsWith('editor-with-instance:')
) {
if (this.cardService.clientRequestIds.has(clientRequestId)) {
realmEventsLogger.debug(
`ignoring because request id is contained in known clientRequestIds`,
event.clientRequestId,
);
} else {
reloadFile = true;
realmEventsLogger.debug(
`reloading file resource ${normalizedURL} because request id is ${clientRequestId}, not contained within known clientRequestIds`,
Object.keys(this.cardService.clientRequestIds),
);
}
} else if (clientRequestId.startsWith('bot-patch:')) {
} else {
reloadFile = true;
realmEventsLogger.debug(
`reloading file resource ${normalizedURL} because request id is ${clientRequestId}`,
`reloading file resource ${normalizedURL} because request id is ${clientRequestId}, not contained within known clientRequestIds`,
Object.keys(this.cardService.clientRequestIds),
);
Comment on lines 380 to 383
}
} else if (
clientRequestId.startsWith('bot-patch:') ||
// create-file writes originate from this host (cardService.saveSource
// with saveType 'create-file' — the path WriteTextFileCommand uses)
// but the FileResource may not yet have any content because its first
// fetch raced indexing and 404'd. The clientRequestId being in
// cardService.clientRequestIds does NOT imply we already have the
// content (unlike the editor: case), so we still need to reload.
clientRequestId.startsWith('create-file:')
) {
reloadFile = true;
realmEventsLogger.debug(
`reloading file resource ${normalizedURL} because request id is ${clientRequestId}`,
);
}

if (reloadFile) {
// Mirrors the store's invalidation path: only reset the loader when
// the rewritten module has actually been imported (which includes
// entries cached as `state: 'broken'`). Resetting unconditionally
// would clone the whole loader on every external write — including
// boxel-cli writes for modules the host never loaded — and drop
// unrelated loaded modules. clearFetchCache is required because
// the module endpoint's ETag is keyed on unix-second-granularity
// `lastModified`; without it, a write landing in the same second
// as the prior fetch can be served as a 304 with the old broken
// body. The store only covers realms it subscribed to (i.e. ones
// it loaded a card instance from), so code-mode-only browsing of
// a .gts whose realm has no loaded instance relies on this path.
if (
hasExecutableExtension(normalizedURL) &&
this.loaderService.loader.isModuleLoaded(normalizedURL)
) {
this.loaderService.resetLoader({
clearFetchCache: true,
reason: 'file-resource-external-invalidation',
});
}
this.read.perform({ force: true });
if (reloadFile) {
// Mirrors the store's invalidation path: only reset the loader when
// the rewritten module has actually been imported (which includes
// entries cached as `state: 'broken'`). Resetting unconditionally
// would clone the whole loader on every external write — including
// boxel-cli writes for modules the host never loaded — and drop
// unrelated loaded modules. clearFetchCache is required because
// the module endpoint's ETag is keyed on unix-second-granularity
// `lastModified`; without it, a write landing in the same second
// as the prior fetch can be served as a 304 with the old broken
// body. The store only covers realms it subscribed to (i.e. ones
// it loaded a card instance from), so code-mode-only browsing of
// a .gts whose realm has no loaded instance relies on this path.
if (
hasExecutableExtension(normalizedURL) &&
this.loaderService.loader.isModuleLoaded(normalizedURL)
) {
this.loaderService.resetLoader({
clearFetchCache: true,
reason: 'file-resource-external-invalidation',
});
}
this.read.perform({ force: true });
}
});
});
}
};

writeTask = restartableTask(
async (
Expand Down
Loading
Loading