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
2 changes: 1 addition & 1 deletion scripts/preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The page stubs the VS Code webview runtime just enough to boot the real bundle:
posts `{ protocol, type: "ready" }` through the shim when it mounts (after
its `window` message listener is wired). The shim detects that and only then
`window.postMessage`es the `document` seed
(`{ protocol, type: "document", content, docVersion: 1, themeKind, canWrite: true, eol }`).
(`{ protocol, type: "document", content, docVersion: 1, themeKind, canWrite: true, eol, externalEpoch: 0, epochGeneration: 1 }`).
`protocol` is `serve.mjs`'s `PREVIEW_PROTOCOL_VERSION`, pinned equal to
`src/shared/protocol.ts`'s `PROTOCOL_VERSION` by
`test/build/preview-server-theme.test.ts` — a stale value makes the shell
Expand Down
3 changes: 3 additions & 0 deletions scripts/preview/preview.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@
themeKind: THEME_KIND,
canWrite: true,
eol: DOC_EOL,
// The identity pair is required; any fixed pair works for a single-session preview.
externalEpoch: 0,
epochGeneration: 1,
},
"*"
);
Expand Down
9 changes: 3 additions & 6 deletions src/extension/session/document-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,8 @@ export type BuildDocumentMessageInput = {
docVersion: number;
themeKind: ThemeKind;
canWrite: boolean;
// The Document identity pair (S3a). The host always emits BOTH now (the wire
// optionality is purely reception tolerance for an old host — see the
// `DocumentMessage` doc block + `isValidEpochIdentity` in protocol.ts), so
// both are required at the builder.
// The Document identity pair (S3a) — both required on the wire (see the
// `DocumentMessage` doc block in protocol.ts).
externalEpoch: number;
epochGeneration: number;
};
Expand All @@ -55,8 +53,7 @@ export type BuildDocumentMessageInput = {
* as a TS error at every call site rather than as a silently-accepted
* extra field, and the Object.keys assertion in the unit test catches
* it before it reaches the wire. Always emits `externalEpoch` +
* `epochGeneration` (the exclusive pair) — the key-set test pins their
* presence. */
* `epochGeneration` — the key-set test pins their presence. */
export function buildDocumentMessage(input: BuildDocumentMessageInput): DocumentMessage {
return {
protocol: PROTOCOL_VERSION,
Expand Down
46 changes: 10 additions & 36 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,35 +253,28 @@ export function isDocumentEol(value: unknown): value is DocumentEol {
* at the protocol layer — see MAX_CONTENT_LENGTH for the directionality
* rationale.
*
* `externalEpoch` + `epochGeneration` are an EXCLUSIVE PAIR — both present or
* both absent; a partial pair is a boundary-INVALID message (validator-
* authoritative). They are wire-OPTIONAL for one release so an old host that
* never sends them does not brick a new webview (absence = "no epoch info" =
* today's unconditional-replay behaviour). Since `PROTOCOL_VERSION` 2 an old
* host is rejected at `isProtocolMatch` before the pair is read, so that
* tolerance is unreachable; making the pair required is a follow-up.
* `externalEpoch` + `epochGeneration` are both REQUIRED — a Document missing
* either is a boundary-INVALID message. Requiring them did not bump
* `PROTOCOL_VERSION`, unlike `eol`: `buildDocumentMessage` already emitted
* both before version 2 existed, so every version-2 host sends the pair and
* the stricter validator rejects no message a conforming host produces.
* Semantics (S3a plumbs them; S3b consumes them): `externalEpoch` is host-owned and monotonic WITHIN one host
* session (starts at 0), advancing whenever document content changed by
* anything other than the webview's own acked edit lineage; `epochGeneration`
* is a per-host-session nonce (minted once at session start — a counter-salted
* timestamp) that identifies WHICH host session's epoch counter it is, so a
* webview surviving a host restart can tell an epoch regression across
* generations from a real advance. Identity, not ordering — never compared for
* magnitude.
*
* The fields are typed as INDEPENDENTLY optional, but the EXCLUSIVE-pair
* contract (both present or both absent; a partial pair is invalid) is enforced
* at the boundary by `isValidEpochIdentity` — the validator is the authority,
* not the type. The host's `buildDocumentMessage` always emits BOTH. */
* magnitude. */
export type DocumentMessage = Envelope & {
type: "document";
content: string;
docVersion: number;
themeKind: ThemeKind;
canWrite: boolean;
eol: DocumentEol;
externalEpoch?: number;
epochGeneration?: number;
externalEpoch: number;
epochGeneration: number;
};

/** Theme change only — no content, no version. Pushed on
Expand Down Expand Up @@ -711,26 +704,6 @@ function isEpochComponent(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}

/** The Document's `externalEpoch` + `epochGeneration` pair is EXCLUSIVE:
* both valid, or both absent. A partial pair (exactly one present) is a
* boundary-INVALID message — the webview must never see a half-formed
* identity (its S3b drop-and-adopt logic enumerates only well-formed states:
* absent, or a valid pair). Absence is tolerated (old host → new webview
* skew): the webview falls back to today's unconditional-replay behaviour.
* Since `PROTOCOL_VERSION` 2 an old host fails `isProtocolMatch` first, so
* this tolerance is unreachable; making the pair required is a follow-up. */
function isValidEpochIdentity(epoch: unknown, generation: unknown): boolean {
const epochAbsent = epoch === undefined;
const generationAbsent = generation === undefined;
if (epochAbsent && generationAbsent) {
return true; // no epoch info — tolerated
}
if (epochAbsent || generationAbsent) {
return false; // partial pair — invalid
}
return isEpochComponent(epoch) && isEpochComponent(generation);
}

function isBoundedContent(value: unknown): value is string {
// Webview→host: cap to bound oversized payloads from a user-controlled
// surface. The exact boundary is asserted in test/shared/protocol.test.ts.
Expand All @@ -750,7 +723,8 @@ export function isHostToWebview(value: unknown): value is HostToWebview {
isThemeKind(v.themeKind) &&
typeof v.canWrite === "boolean" &&
isDocumentEol(v.eol) &&
isValidEpochIdentity(v.externalEpoch, v.epochGeneration)
isEpochComponent(v.externalEpoch) &&
isEpochComponent(v.epochGeneration)
);
case "theme":
return isThemeKind(v.themeKind);
Expand Down
Loading
Loading