chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -168,6 +168,28 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
let quiescing: InternalWriterLease<Value> | null = null;
|
||||
let transitionCandidate: InternalWriterLease<Value> | null = null;
|
||||
let closePromise: Promise<RealtimeResult<void>> | null = null;
|
||||
/**
|
||||
* R-03. Writers whose lease was fail-closed but whose tail may still be
|
||||
* running. Membership keeps `close()` honest about quiescence.
|
||||
*/
|
||||
const retiredWriters = new Set<InternalWriterLease<Value>>();
|
||||
/**
|
||||
* RT-RR-04. Checkpoint work is an external authority call like a writer tail,
|
||||
* so it belongs in a physical-task registry from invocation to settlement.
|
||||
* Racing it against a timeout bounded the public wait but left `close()` free
|
||||
* to report success while the checkpoint was still running.
|
||||
*/
|
||||
const checkpointTasks = new Set<Promise<unknown>>();
|
||||
|
||||
/** RT-RR-03. Prunes a settled writer without needing another `close()`. */
|
||||
function trackRetiredWriter(lease: InternalWriterLease<Value>): void {
|
||||
retiredWriters.add(lease);
|
||||
void lease.tail
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
retiredWriters.delete(lease);
|
||||
});
|
||||
}
|
||||
|
||||
active = createWriterLease(dependencies.initial.writer);
|
||||
|
||||
@@ -568,6 +590,12 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
(value) => ({ kind: "VALUE" as const, value }),
|
||||
() => ({ kind: "REJECTED" as const }),
|
||||
);
|
||||
// RT-RR-04. Registered at the moment the authority is called, and removed
|
||||
// only when it settles, so `close()` cannot report quiescence over it.
|
||||
checkpointTasks.add(operation);
|
||||
void operation.finally(() => {
|
||||
checkpointTasks.delete(operation);
|
||||
});
|
||||
const timeout = Promise.resolve()
|
||||
.then(async () => {
|
||||
await clock.sleep(
|
||||
@@ -638,6 +666,33 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
return realtimeSuccess(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* RT-RR-04. The writer-tail quiescence bound applied to any retained physical
|
||||
* task, so checkpoint work is proved settled on the same terms.
|
||||
*/
|
||||
async function awaitTaskQuiescence(
|
||||
task: Promise<unknown>,
|
||||
): Promise<QuiescenceOutcome> {
|
||||
const timer = new AbortController();
|
||||
const settled = task.then(
|
||||
() => "QUIESCED" as const,
|
||||
() => "QUIESCED" as const,
|
||||
);
|
||||
const timeout = Promise.resolve()
|
||||
.then(async () => {
|
||||
await clock.sleep(limits.quiescenceTimeoutMs, timer.signal);
|
||||
return "TIMED_OUT" as const;
|
||||
})
|
||||
.catch(() =>
|
||||
timer.signal.aborted
|
||||
? ("QUIESCED" as const)
|
||||
: ("TIMER_FAILED" as const),
|
||||
);
|
||||
const outcome = await Promise.race([settled, timeout]);
|
||||
timer.abort();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function awaitQuiescence(
|
||||
lease: InternalWriterLease<Value>,
|
||||
): Promise<QuiescenceOutcome> {
|
||||
@@ -665,7 +720,13 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
}
|
||||
|
||||
function close(): Promise<RealtimeResult<void>> {
|
||||
closePromise ??= performClose();
|
||||
// RT-RR-03. Only a close that is still running is shared. Caching the first
|
||||
// timeout forever meant a writer that later settled could never be proved
|
||||
// quiescent: every subsequent close replayed the stale failure and the
|
||||
// retained registry could never be pruned.
|
||||
closePromise ??= performClose().finally(() => {
|
||||
closePromise = null;
|
||||
});
|
||||
return closePromise;
|
||||
}
|
||||
|
||||
@@ -673,21 +734,35 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
lifecycleGeneration += 1;
|
||||
state = "CLOSED";
|
||||
transitioning = true;
|
||||
// R-03. Current and previously retired writers are waited on together and
|
||||
// deduplicated, so a writer dropped by an overflow fail-close is still
|
||||
// proved quiescent before close reports success.
|
||||
const writers = uniqueLeases([
|
||||
active,
|
||||
probe?.lease ?? null,
|
||||
quiescing,
|
||||
transitionCandidate,
|
||||
...retiredWriters,
|
||||
]);
|
||||
active = null;
|
||||
const selectedProbe = probe;
|
||||
probe = null;
|
||||
selectedProbe?.buffer.splice(0);
|
||||
if (selectedProbe) selectedProbe.bufferedBytes = 0;
|
||||
for (const writer of writers) writer.controller.abort();
|
||||
const outcomes = await Promise.all(
|
||||
writers.map(async (writer) => await awaitQuiescence(writer)),
|
||||
);
|
||||
for (const writer of writers) {
|
||||
// RT-RR-03. Every writer this close fences is retained until its tail
|
||||
// actually settles, so a later close still sees a writer that has not
|
||||
// finished — and stops seeing it the moment it does.
|
||||
trackRetiredWriter(writer);
|
||||
writer.controller.abort();
|
||||
}
|
||||
const outcomes = await Promise.all([
|
||||
...writers.map(async (writer) => await awaitQuiescence(writer)),
|
||||
// RT-RR-04. Checkpoint work is drained on the same terms as a writer tail.
|
||||
...[...checkpointTasks].map(
|
||||
async (task) => await awaitTaskQuiescence(task),
|
||||
),
|
||||
]);
|
||||
quiescing = null;
|
||||
transitionCandidate = null;
|
||||
transitioning = false;
|
||||
@@ -771,20 +846,33 @@ export function createLivePollHandoffCoordinator<Value>(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* R-03. A fail-close aborts every lease, but the underlying writers may be
|
||||
* non-cooperative and still running. They move into the retired set before
|
||||
* their references are cleared, so a later `close()` cannot report success
|
||||
* while an abandoned writer is still executing.
|
||||
*/
|
||||
function failClosed(): void {
|
||||
lifecycleGeneration += 1;
|
||||
state = "CLOSED";
|
||||
transitioning = false;
|
||||
active?.controller.abort();
|
||||
probe?.lease.controller.abort();
|
||||
quiescing?.controller.abort();
|
||||
transitionCandidate?.controller.abort();
|
||||
for (const writer of uniqueLeases([
|
||||
active,
|
||||
probe?.lease ?? null,
|
||||
quiescing,
|
||||
transitionCandidate,
|
||||
])) {
|
||||
writer.controller.abort();
|
||||
trackRetiredWriter(writer);
|
||||
}
|
||||
active = null;
|
||||
if (probe) {
|
||||
probe.buffer.length = 0;
|
||||
probe.bufferedBytes = 0;
|
||||
}
|
||||
probe = null;
|
||||
quiescing = null;
|
||||
transitionCandidate = null;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -41,6 +41,27 @@ import {
|
||||
type RealtimeDataSnapshot,
|
||||
} from "./result.ts";
|
||||
|
||||
/**
|
||||
* R-02. Lifecycle is orthogonal to freshness. `DRAINING` means the coordinator
|
||||
* has revoked commit capability and stopped admitting work, but a
|
||||
* non-cooperative task it started is still running and is deliberately retained
|
||||
* until it actually settles.
|
||||
*/
|
||||
export type RealtimeStreamLifecycle = "OPEN" | "DRAINING" | "CLOSED";
|
||||
|
||||
export type RealtimeStreamTaskLimits = Readonly<{
|
||||
effectTimeoutMs: number;
|
||||
recoveryTimeoutMs: number;
|
||||
drainTimeoutMs: number;
|
||||
}>;
|
||||
|
||||
export const DEFAULT_REALTIME_STREAM_TASK_LIMITS: RealtimeStreamTaskLimits =
|
||||
Object.freeze({
|
||||
effectTimeoutMs: 5_000,
|
||||
recoveryTimeoutMs: 10_000,
|
||||
drainTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
export type RealtimeStreamCoordinatorDependencies = Readonly<{
|
||||
registry: RealtimePolicyRegistry;
|
||||
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
|
||||
@@ -48,6 +69,10 @@ export type RealtimeStreamCoordinatorDependencies = Readonly<{
|
||||
scope: RealtimeScopeSnapshot;
|
||||
now?: () => number;
|
||||
observe?: RealtimeEventObservationSink;
|
||||
taskLimits?: Partial<RealtimeStreamTaskLimits>;
|
||||
/** Test seam for the bounded task deadline. */
|
||||
scheduleTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearScheduledTimeout?: (handle: unknown) => void;
|
||||
}>;
|
||||
|
||||
export type RealtimeStreamCoordinator = Readonly<{
|
||||
@@ -66,7 +91,13 @@ export type RealtimeStreamCoordinator = Readonly<{
|
||||
): RealtimeResult<void>;
|
||||
getResumeState(streamId: StreamRegistrationId): RealtimeResumeState | null;
|
||||
inspect(streamId: StreamRegistrationId): RealtimeStreamInspection;
|
||||
close(): void;
|
||||
lifecycle(streamId: StreamRegistrationId): RealtimeStreamLifecycle;
|
||||
/**
|
||||
* R-02. Bounded quiescence. Success means every tracked task actually
|
||||
* settled; `IDLE_TIMEOUT` means the coordinator is still `DRAINING` and the
|
||||
* caller must not assume a clean teardown.
|
||||
*/
|
||||
close(): Promise<RealtimeResult<void>>;
|
||||
}>;
|
||||
|
||||
type DedupeEntry = Readonly<{
|
||||
@@ -96,6 +127,25 @@ type StreamState = {
|
||||
awaitingTransportBarrier: boolean;
|
||||
barrierCheckpoint: RealtimeRecoveryCheckpoint | null;
|
||||
closed: boolean;
|
||||
lifecycle: RealtimeStreamLifecycle;
|
||||
/**
|
||||
* RT-RR-01. Every physical task this coordinator has handed to an external
|
||||
* authority, from the moment of the call until it settles. Registering only
|
||||
* after a timeout meant a `close()` that arrived first saw an empty set and
|
||||
* reported quiescence while the raw task was still running.
|
||||
*/
|
||||
retainedTasks: Set<Promise<unknown>>;
|
||||
/**
|
||||
* The subset of `retainedTasks` whose public wait already ended. These are
|
||||
* what keep the stream `DRAINING` and block new admission.
|
||||
*/
|
||||
timedOutTasks: Set<Promise<unknown>>;
|
||||
/**
|
||||
* RT-RR-02. Set when a task was abandoned at its deadline. The resume token
|
||||
* is discarded with it, so the next admitted event cannot skip authoritative
|
||||
* recovery on the strength of state a timed-out effect may have invalidated.
|
||||
*/
|
||||
recoveryRequired: boolean;
|
||||
};
|
||||
|
||||
const SNAPSHOT_CHECKPOINT_KEYS = Object.freeze([
|
||||
@@ -120,9 +170,94 @@ export function createRealtimeStreamCoordinator(
|
||||
): RealtimeStreamCoordinator {
|
||||
assertDependencies(dependencies);
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const limits: RealtimeStreamTaskLimits = Object.freeze({
|
||||
...DEFAULT_REALTIME_STREAM_TASK_LIMITS,
|
||||
...dependencies.taskLimits,
|
||||
});
|
||||
const scheduleTimeout =
|
||||
dependencies.scheduleTimeout ??
|
||||
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
|
||||
const clearScheduledTimeout =
|
||||
dependencies.clearScheduledTimeout ??
|
||||
((handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
});
|
||||
const states = new Map<StreamRegistrationId, StreamState>();
|
||||
let closed = false;
|
||||
|
||||
/** RT-02. Releasing a timer is best effort and never a public failure. */
|
||||
const clearTimerSafely = (handle: unknown): void => {
|
||||
try {
|
||||
clearScheduledTimeout(handle);
|
||||
} catch {
|
||||
// A broken scheduler cannot change an already classified outcome.
|
||||
}
|
||||
};
|
||||
|
||||
const TASK_TIMED_OUT = Symbol("REALTIME_TASK_TIMED_OUT");
|
||||
|
||||
/**
|
||||
* R-02. Bounds the public wait without discarding the task. A task that
|
||||
* outlives its deadline is retained so `close()` can report honestly whether
|
||||
* the stream is actually quiescent.
|
||||
*/
|
||||
async function awaitTaskWithinDeadline<Value>(
|
||||
state: StreamState,
|
||||
invoke: () => Promise<Value> | Value,
|
||||
timeoutMs: number,
|
||||
revokeAndAbort: () => void,
|
||||
): Promise<Value | typeof TASK_TIMED_OUT> {
|
||||
// RT-01. The collaborator is invoked on the next microtask, after the task
|
||||
// is already in the registry. Calling it first left a window in which an
|
||||
// authority that re-entered `close()` from inside its own invocation saw an
|
||||
// empty registry, so `close()` reported quiescence while its effect was
|
||||
// still running.
|
||||
const task = Promise.resolve().then(invoke);
|
||||
task.catch(() => {});
|
||||
// RT-RR-01. The task is a physical effect the moment it is created, so it
|
||||
// is registered here rather than when its public wait happens to expire.
|
||||
state.retainedTasks.add(task);
|
||||
void task
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.retainedTasks.delete(task);
|
||||
state.timedOutTasks.delete(task);
|
||||
if (state.timedOutTasks.size === 0 && state.lifecycle === "DRAINING") {
|
||||
state.lifecycle = state.closed ? "CLOSED" : "OPEN";
|
||||
if (!state.closed) state.freshness = "STALE";
|
||||
}
|
||||
});
|
||||
// RT-02. A scheduler that cannot install the deadline leaves the wait
|
||||
// unbounded. Letting the exception escape turned a typed realtime result
|
||||
// into a native rejection and — through the caller's own catch — started a
|
||||
// recovery that overlapped the effect still running, so an install failure
|
||||
// fails closed as an expired deadline instead.
|
||||
let handle: unknown;
|
||||
let installed = false;
|
||||
const timeout = new Promise<typeof TASK_TIMED_OUT>((resolve) => {
|
||||
try {
|
||||
handle = scheduleTimeout(() => resolve(TASK_TIMED_OUT), timeoutMs);
|
||||
installed = true;
|
||||
} catch {
|
||||
resolve(TASK_TIMED_OUT);
|
||||
}
|
||||
});
|
||||
const outcome = await Promise.race([task, timeout]);
|
||||
if (installed) clearTimerSafely(handle);
|
||||
if (outcome !== TASK_TIMED_OUT) return outcome;
|
||||
|
||||
// The commit capability is revoked immediately; the work itself is not.
|
||||
revokeAndAbort();
|
||||
state.lifecycle = "DRAINING";
|
||||
state.timedOutTasks.add(task);
|
||||
// RT-RR-02. An abandoned task may have applied part of its effect, so the
|
||||
// resume token it was based on is no longer authoritative evidence.
|
||||
state.recoveryRequired = true;
|
||||
state.resumeState = null;
|
||||
state.freshness = "UNKNOWN";
|
||||
return TASK_TIMED_OUT;
|
||||
}
|
||||
|
||||
for (const registration of dependencies.registry.listStreams()) {
|
||||
states.set(registration.id, {
|
||||
registration,
|
||||
@@ -143,6 +278,10 @@ export function createRealtimeStreamCoordinator(
|
||||
awaitingTransportBarrier: false,
|
||||
barrierCheckpoint: null,
|
||||
closed: false,
|
||||
lifecycle: "OPEN",
|
||||
retainedTasks: new Set(),
|
||||
timedOutTasks: new Set(),
|
||||
recoveryRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,6 +297,11 @@ export function createRealtimeStreamCoordinator(
|
||||
realtimeFailure("MALFORMED_EVENT", "RECEIVE"),
|
||||
);
|
||||
}
|
||||
const draining = states.get(event.envelope.streamId);
|
||||
if (draining && draining.lifecycle === "DRAINING") {
|
||||
// R-02. New work is refused while a retained task is still running.
|
||||
return Promise.resolve(realtimeFailure("CLOSED", "RECEIVE"));
|
||||
}
|
||||
const state = states.get(event.envelope.streamId);
|
||||
if (!state) {
|
||||
return Promise.resolve(
|
||||
@@ -258,6 +402,10 @@ export function createRealtimeStreamCoordinator(
|
||||
return realtimeFailure("ABORTED", "RECEIVE");
|
||||
}
|
||||
if (closed || state.closed) return dropped("CLOSED");
|
||||
// RT-RR-02. Admission happened when this event was queued; execution is a
|
||||
// second decision. A queue entry admitted before the stream entered
|
||||
// DRAINING must not start running inside it.
|
||||
if (state.lifecycle === "DRAINING") return dropped("CLOSED");
|
||||
if (expectedGeneration !== state.processingGeneration) {
|
||||
return dropped("SCOPE_FENCED");
|
||||
}
|
||||
@@ -273,7 +421,11 @@ export function createRealtimeStreamCoordinator(
|
||||
signal,
|
||||
);
|
||||
}
|
||||
if (state.freshness === "UNKNOWN" || !state.resumeState) {
|
||||
if (
|
||||
state.recoveryRequired ||
|
||||
state.freshness === "UNKNOWN" ||
|
||||
!state.resumeState
|
||||
) {
|
||||
return recoverForAccept(state, "INITIALIZE", true, signal);
|
||||
}
|
||||
if (event.envelope.streamEpoch !== state.resumeState.streamEpoch) {
|
||||
@@ -384,19 +536,36 @@ export function createRealtimeStreamCoordinator(
|
||||
!effectAbort.signal.aborted &&
|
||||
scopeIsCurrent(dependencies.scope);
|
||||
let effect: unknown;
|
||||
let effectTimedOut = false;
|
||||
try {
|
||||
effect = await dependencies.authority.effects.apply(
|
||||
eventType.effectProfileId,
|
||||
mapped.value,
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
eventType: eventType.id,
|
||||
occurredAt: event.envelope.occurredAt,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
isCurrent: effectIsCurrent,
|
||||
}),
|
||||
effectAbort.signal,
|
||||
const applied = await awaitTaskWithinDeadline(
|
||||
state,
|
||||
() =>
|
||||
dependencies.authority.effects.apply(
|
||||
eventType.effectProfileId,
|
||||
mapped.value,
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
eventType: eventType.id,
|
||||
occurredAt: event.envelope.occurredAt,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
isCurrent: effectIsCurrent,
|
||||
}),
|
||||
effectAbort.signal,
|
||||
),
|
||||
limits.effectTimeoutMs,
|
||||
() => {
|
||||
// Commit capability is revoked permanently for this attempt.
|
||||
effectLeaseActive = false;
|
||||
effectAbort.abort();
|
||||
},
|
||||
);
|
||||
if (applied === TASK_TIMED_OUT) {
|
||||
effectTimedOut = true;
|
||||
effect = null;
|
||||
} else {
|
||||
effect = applied;
|
||||
}
|
||||
} catch {
|
||||
effect = null;
|
||||
} finally {
|
||||
@@ -406,6 +575,17 @@ export function createRealtimeStreamCoordinator(
|
||||
state.activeEffectAbort = null;
|
||||
}
|
||||
}
|
||||
if (effectTimedOut) {
|
||||
// R-02. Bounded for the caller; the underlying task stays tracked.
|
||||
observe({
|
||||
operation: "APPLY",
|
||||
outcome: "FAILED",
|
||||
streamId: state.registration.id,
|
||||
eventType: event.envelope.eventType,
|
||||
reason: "IDLE_TIMEOUT",
|
||||
});
|
||||
return realtimeFailure("IDLE_TIMEOUT", "APPLY", false);
|
||||
}
|
||||
|
||||
if (closed || state.closed) {
|
||||
return dropped("CLOSED");
|
||||
@@ -483,7 +663,7 @@ export function createRealtimeStreamCoordinator(
|
||||
calledFromCurrentJob: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RealtimeResult<RealtimeRecoveryCheckpoint>> {
|
||||
if (closed || state.closed) {
|
||||
if (closed || state.closed || state.lifecycle === "DRAINING") {
|
||||
return Promise.resolve(realtimeFailure("CLOSED", "RECOVER"));
|
||||
}
|
||||
if (!scopeIsCurrent(dependencies.scope)) {
|
||||
@@ -542,22 +722,49 @@ export function createRealtimeStreamCoordinator(
|
||||
|
||||
let recovered: unknown;
|
||||
let recoveryThrew = false;
|
||||
let recoveryTimedOut = false;
|
||||
try {
|
||||
recovered = await dependencies.authority.recovery.recover(
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
reason,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
signal: recoveryAbort.signal,
|
||||
isCurrent: recoveryIsCurrent,
|
||||
}),
|
||||
const outcome = await awaitTaskWithinDeadline(
|
||||
state,
|
||||
() =>
|
||||
dependencies.authority.recovery.recover(
|
||||
Object.freeze({
|
||||
streamId: state.registration.id,
|
||||
reason,
|
||||
scopeGeneration: dependencies.scope.generation,
|
||||
signal: recoveryAbort.signal,
|
||||
isCurrent: recoveryIsCurrent,
|
||||
}),
|
||||
),
|
||||
limits.recoveryTimeoutMs,
|
||||
() => {
|
||||
recoveryLeaseActive = false;
|
||||
recoveryAbort.abort();
|
||||
},
|
||||
);
|
||||
if (outcome === TASK_TIMED_OUT) {
|
||||
recoveryTimedOut = true;
|
||||
recovered = null;
|
||||
} else {
|
||||
recovered = outcome;
|
||||
}
|
||||
} catch {
|
||||
recovered = null;
|
||||
recoveryThrew = true;
|
||||
} finally {
|
||||
recoveryLeaseActive = false;
|
||||
}
|
||||
if (recoveryTimedOut) {
|
||||
// R-02. A late checkpoint from this attempt can never commit.
|
||||
state.freshness = "UNKNOWN";
|
||||
observe({
|
||||
operation: "RECOVER",
|
||||
outcome: "FAILED",
|
||||
streamId: state.registration.id,
|
||||
reason: "IDLE_TIMEOUT",
|
||||
});
|
||||
return realtimeFailure("IDLE_TIMEOUT", "RECOVER", false);
|
||||
}
|
||||
if (closed || state.closed) {
|
||||
state.freshness = "UNKNOWN";
|
||||
return realtimeFailure("CLOSED", "RECOVER");
|
||||
@@ -631,6 +838,9 @@ export function createRealtimeStreamCoordinator(
|
||||
validatedResumeState as RealtimeRecoveryCheckpoint;
|
||||
|
||||
state.resumeState = resumeState;
|
||||
// RT-RR-02. Authoritative recovery is the only thing that clears the
|
||||
// requirement a timed-out task imposed.
|
||||
state.recoveryRequired = false;
|
||||
state.awaitingTransportBarrier =
|
||||
recoveryRequiresTransportBarrier(state.registration);
|
||||
state.barrierCheckpoint = state.awaitingTransportBarrier
|
||||
@@ -806,7 +1016,53 @@ export function createRealtimeStreamCoordinator(
|
||||
});
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
function lifecycleOf(streamId: StreamRegistrationId): RealtimeStreamLifecycle {
|
||||
const state = states.get(streamId);
|
||||
if (!state) return "CLOSED";
|
||||
return state.lifecycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* R-02. `close()` fences immediately but reports honestly: success only when
|
||||
* every retained task actually settled within the drain bound.
|
||||
*/
|
||||
async function close(): Promise<RealtimeResult<void>> {
|
||||
fenceAllStates();
|
||||
const retained = [...states.values()].flatMap((state) => [
|
||||
...state.retainedTasks,
|
||||
]);
|
||||
if (retained.length === 0) {
|
||||
for (const state of states.values()) state.lifecycle = "CLOSED";
|
||||
return realtimeSuccess(undefined);
|
||||
}
|
||||
let handle: unknown;
|
||||
let installed = false;
|
||||
const drained = await Promise.race([
|
||||
Promise.allSettled(retained).then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
try {
|
||||
handle = scheduleTimeout(
|
||||
() => resolve(false),
|
||||
limits.drainTimeoutMs,
|
||||
);
|
||||
installed = true;
|
||||
} catch {
|
||||
// RT-02. Without a drain bound this call cannot prove quiescence, so
|
||||
// it reports the honest failure rather than rejecting natively.
|
||||
resolve(false);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
if (installed) clearTimerSafely(handle);
|
||||
if (!drained) {
|
||||
// Still DRAINING: the caller must not treat this as quiescence.
|
||||
return realtimeFailure("IDLE_TIMEOUT", "CLOSE", false);
|
||||
}
|
||||
for (const state of states.values()) state.lifecycle = "CLOSED";
|
||||
return realtimeSuccess(undefined);
|
||||
}
|
||||
|
||||
function fenceAllStates(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const state of states.values()) {
|
||||
@@ -826,6 +1082,8 @@ export function createRealtimeStreamCoordinator(
|
||||
state.eventIds.clear();
|
||||
state.sequences.clear();
|
||||
state.dedupeBytes = 0;
|
||||
state.lifecycle =
|
||||
state.retainedTasks.size > 0 ? "DRAINING" : "CLOSED";
|
||||
}
|
||||
observe({
|
||||
operation: "CLOSE",
|
||||
@@ -911,6 +1169,7 @@ export function createRealtimeStreamCoordinator(
|
||||
confirmTransportBarrier,
|
||||
getResumeState,
|
||||
inspect,
|
||||
lifecycle: lifecycleOf,
|
||||
close,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,10 +222,11 @@ export function decodeWebSocketServerFrame(
|
||||
if (!isPositiveInteger(maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
if (exceedsUtf8ByteLimit(input, maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
// Only an admitted frame pays for the exact length.
|
||||
const byteLength = utf8ByteLength(input);
|
||||
if (
|
||||
hasDuplicateJsonMembers(input, {
|
||||
maxDepth: MAX_FRAME_STRUCTURE_DEPTH,
|
||||
@@ -294,10 +295,12 @@ export function encodeWebSocketClientFrame(
|
||||
} catch {
|
||||
return protocolFailure("MALFORMED_FRAME");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
if (byteLength > maxFrameBytes) {
|
||||
// Reject before allocating the encoded copy; the exact length is only
|
||||
// computed for a frame that is going to be sent.
|
||||
if (exceedsUtf8ByteLimit(value, maxFrameBytes)) {
|
||||
return protocolFailure("FRAME_TOO_LARGE");
|
||||
}
|
||||
const byteLength = utf8ByteLength(value);
|
||||
return Object.freeze({ ok: true, value, byteLength });
|
||||
}
|
||||
|
||||
@@ -466,10 +469,45 @@ function isUnsignedSequence(input: unknown): input is string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R-05. Admission before allocation.
|
||||
*
|
||||
* UTF-8 needs at least one byte per UTF-16 code unit, so a string longer than
|
||||
* the cap is already over it and is rejected without touching an encoder. The
|
||||
* remainder is counted incrementally with an early exit, so a hostile frame
|
||||
* never causes a second full-size buffer. A valid surrogate pair counts as four
|
||||
* bytes and a lone surrogate as the three-byte replacement sequence, exactly
|
||||
* like `TextEncoder`.
|
||||
*/
|
||||
function utf8ByteLength(input: string): number {
|
||||
return new TextEncoder().encode(input).byteLength;
|
||||
}
|
||||
|
||||
function exceedsUtf8ByteLimit(input: string, maxBytes: number): boolean {
|
||||
if (input.length > maxBytes) return true;
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code < 0x80) bytes += 1;
|
||||
else if (code < 0x800) bytes += 2;
|
||||
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = index + 1 < input.length ? input.charCodeAt(index + 1) : 0;
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
// Lone high surrogate: TextEncoder emits U+FFFD.
|
||||
bytes += 3;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
// Lone low surrogate: TextEncoder emits U+FFFD.
|
||||
bytes += 3;
|
||||
} else bytes += 3;
|
||||
if (bytes > maxBytes) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function protocolFailure(
|
||||
code: WebSocketProtocolFailure["code"],
|
||||
): WebSocketProtocolResult<never> {
|
||||
|
||||
Reference in New Issue
Block a user