diff --git a/packages/cli/commands/test-command.ts b/packages/cli/commands/test-command.ts index 98f83044f7..950aa7cd63 100644 --- a/packages/cli/commands/test-command.ts +++ b/packages/cli/commands/test-command.ts @@ -1,7 +1,9 @@ import { expandGlob } from "@std/fs"; import { resolve } from "@std/path"; -import { Command } from "@cliffy/command"; +import { Command, EnumType } from "@cliffy/command"; + +import { CFC_ENFORCEMENT_MODES } from "@commonfabric/runner/cfc"; import { cliText } from "../lib/cli-name.ts"; import { @@ -19,6 +21,8 @@ export function createTestCommand( ) { return new Command() .name("test") + .type("cfc-enforcement", new EnumType(CFC_ENFORCEMENT_MODES)) + .type("cfc-flow", new EnumType(["off", "derive", "observe", "persist"])) .description("Run pattern tests (.test.tsx files).") .example( cliText("cf test ./counter.test.tsx"), @@ -38,6 +42,19 @@ export function createTestCommand( ), "Run with custom root for resolving imports from sibling directories.", ) + .option( + "--cfc-enforcement-mode ", + "Override the test runtime CFC enforcement mode.", + ) + .option( + "--cfc-flow-labels ", + "Flow labels: off, derive (runtime observe), or persist.", + ) + .option( + "--cfc-shell-posture", + "Use enforce-explicit enforcement and persist flow labels.", + { conflicts: ["cfc-enforcement-mode", "cfc-flow-labels"] }, + ) .option( "--verbose", "Show detailed execution logs.", @@ -179,6 +196,14 @@ export function createTestCommand( // Run tests const { failed } = await runTests(uniqueTestFiles, { verbose: options.verbose, + cfcEnforcementMode: options.cfcShellPosture + ? "enforce-explicit" + : options.cfcEnforcementMode, + cfcFlowLabels: options.cfcShellPosture + ? "persist" + : options.cfcFlowLabels === "derive" + ? "observe" + : options.cfcFlowLabels, noIdempotencyCheck: options.idempotencyCheck === false, root, dataFilePaths: options.datafile?.map((path: string) => diff --git a/packages/cli/lib/multi-user-test-runner.ts b/packages/cli/lib/multi-user-test-runner.ts index 38e2c76ead..508e5a3123 100644 --- a/packages/cli/lib/multi-user-test-runner.ts +++ b/packages/cli/lib/multi-user-test-runner.ts @@ -276,12 +276,24 @@ export async function runMultiUserTestPattern( // A test that names a mode names it for every participant, the way // the single-user runner honors it. cfcEnforcementMode: options.cfcEnforcementMode, + cfcFlowLabels: options.cfcFlowLabels, }) as ParticipantInitResult; assertParticipantRung( spec.name, options.cfcEnforcementMode, init.cfcEnforcementMode, ); + if ( + options.cfcFlowLabels !== undefined && + init.cfcFlowLabels !== options.cfcFlowLabels + ) { + throw new Error( + `Participant "${spec.name}" resolved flow labels to ${init.cfcFlowLabels}, expected ${options.cfcFlowLabels}`, + ); + } + console.log( + ` [${spec.name}] CFC posture: enforcement=${init.cfcEnforcementMode} flowLabels=${init.cfcFlowLabels}`, + ); participants.push({ spec, worker, diff --git a/packages/cli/lib/multi-user-test-worker.ts b/packages/cli/lib/multi-user-test-worker.ts index ae6b4a86fb..d7ee89911a 100644 --- a/packages/cli/lib/multi-user-test-worker.ts +++ b/packages/cli/lib/multi-user-test-worker.ts @@ -31,6 +31,7 @@ import type { RealmEncodedValue } from "@commonfabric/data-model/codec-realm"; import { CFC_ENFORCEMENT_MODES, type CfcEnforcementMode, + type CfcFlowLabelsMode, isCfcEnforcementMode, } from "@commonfabric/runner/cfc"; import { @@ -108,6 +109,9 @@ export interface ParticipantInitResult { * named, so a participant that came up on another rung says so. */ cfcEnforcementMode: CfcEnforcementMode; + + /** Flow-label mode resolved by this participant runtime. */ + cfcFlowLabels: CfcFlowLabelsMode; } const SETUP_CAUSE = "multi-user-test-setup"; @@ -320,6 +324,17 @@ const handlers: Record< */ async init(args) { const requestedMode = requestedEnforcementMode(args.cfcEnforcementMode); + const flowLabels = args.cfcFlowLabels; + if ( + flowLabels !== undefined && flowLabels !== "off" && + flowLabels !== "observe" && flowLabels !== "persist" + ) { + throw new Error( + `Initialization cfcFlowLabels is ${ + String(flowLabels) + }, not off, observe, or persist`, + ); + } const identity = await Identity.fromKeyPair( keyPairFromRealmValue( args.identity as RealmEncodedValue, @@ -355,6 +370,7 @@ const handlers: Record< experimental: experimentalOptionsFromEnv(Deno.env.get), errorHandlers: [(error: Error) => runtimeErrors.push(String(error))], moduleByteCache: getDefaultModuleByteCache(), + ...(flowLabels !== undefined ? { cfcFlowLabels: flowLabels } : {}), ...(requestedMode !== undefined ? { cfcEnforcementMode: requestedMode } : {}), @@ -532,6 +548,7 @@ const handlers: Record< await (resultCell.key("allowConsoleWarnings") as Cell) .pull() === true, cfcEnforcementMode: rt().cfcEnforcementMode, + cfcFlowLabels: rt().cfcFlowLabels, }; return result; }, diff --git a/packages/cli/lib/test-runner.ts b/packages/cli/lib/test-runner.ts index da5ac1a373..6408d5bfaf 100644 --- a/packages/cli/lib/test-runner.ts +++ b/packages/cli/lib/test-runner.ts @@ -67,7 +67,10 @@ import type { SettleStats, Stream, } from "@commonfabric/runner"; -import type { CfcEnforcementMode } from "@commonfabric/runner/cfc"; +import type { + CfcEnforcementMode, + CfcFlowLabelsMode, +} from "@commonfabric/runner/cfc"; import { type CDFPoint, clearTimingMeasures, @@ -382,6 +385,9 @@ export interface TestRunnerOptions { /** Override CFC enforcement mode for the test runtime. */ cfcEnforcementMode?: CfcEnforcementMode; + /** Override flow-label propagation for every test runtime. */ + cfcFlowLabels?: CfcFlowLabelsMode; + /** Shared compiled-module-byte cache for direct harness compiles. */ moduleByteCache?: ModuleByteCache; @@ -598,6 +604,11 @@ function printLoggerStats( label?: string, statsInclude: string[] = [], ): void { + statsInclude = [ + "cfc", + "extended-storage-transaction/prepareCfc", + ...statsInclude, + ]; const counts = useDelta ? getGlobalLogCountDeltas() : getGlobalLogCounts(); const dp = useDelta ? "Δ" : ""; const labelStr = label ? ` | ${label}:` : ":"; @@ -649,6 +660,20 @@ function printLoggerStats( } } + // Zero rows distinguish a phase that did no CFC work from a missing probe. + for ( + const name of [ + "extended-storage-transaction/prepareCfc", + "cfc/deriveFlowJoin", + "cfc/collectConsumedLabel", + "cfc/preparedDigestFor", + ] + ) { + if (!entries.some((entry) => entry.name === name)) { + entries.push({ name, n: 0, total: 0, avg: 0, p50: 0, p95: 0, max: 0 }); + } + } + if (entries.length > 0) { entries.sort((a, b) => b.total - a.total); const topEntries = entries.slice(0, 10); @@ -1159,6 +1184,9 @@ export async function runTestPattern( ...(options.cfcEnforcementMode !== undefined ? { cfcEnforcementMode: options.cfcEnforcementMode } : {}), + ...(options.cfcFlowLabels !== undefined + ? { cfcFlowLabels: options.cfcFlowLabels } + : {}), ...(options.storageHost?.onPatternInstantiated !== undefined ? { onPatternInstantiated: options.storageHost.onPatternInstantiated } : {}), @@ -1176,6 +1204,9 @@ export async function runTestPattern( }, })), ); + console.log( + ` CFC posture: enforcement=${runtime.cfcEnforcementMode} flowLabels=${runtime.cfcFlowLabels}`, + ); if (!options.noIdempotencyCheck) runtime.enableIdempotencyCheck(); else if (options.verbose) { console.log(" Idempotency verification disabled for this measurement."); @@ -1681,9 +1712,13 @@ export async function runTestPattern( const renderName = `render_${renderCount}`; try { if (!stepValue.skip) { - await materializeTestVDOM( - stepCell.key("render") as Cell, - () => settleRuntime(renderName, 20), + await withPhase( + ["runTestPattern", "step", renderName, "materialize"], + () => + materializeTestVDOM( + stepCell.key("render") as Cell, + () => settleRuntime(renderName, 20), + ), ); if (options.verbose) console.log(` ◇ ${renderName}`); } else if (options.verbose) { @@ -1691,6 +1726,17 @@ export async function runTestPattern( } } finally { printReadCost(renderName, itemStart); + const duration = performance.now() - itemStart; + if ( + options.verbose && duration >= (options.statsThreshold ?? 5000) + ) { + printLoggerStats( + performance.now() - startTime, + true, + `${renderName} took ${fmtMs(duration)}`, + options.statsInclude, + ); + } } continue; } diff --git a/packages/runner/src/cfc/canonical.ts b/packages/runner/src/cfc/canonical.ts index 5c45983a9d..966321e337 100644 --- a/packages/runner/src/cfc/canonical.ts +++ b/packages/runner/src/cfc/canonical.ts @@ -1,4 +1,5 @@ import { hashStringOf } from "@commonfabric/data-model"; +import { getLogger } from "@commonfabric/utils/logger"; import type { CfcConfClause } from "./clause.ts"; import { encodePointer } from "../../../memory/v2/path.ts"; import type { @@ -524,5 +525,14 @@ export const canonicalizePreparedDigestInput = ( : {}), }); -export const preparedDigestFor = (input: PreparedDigestInput): string => - hashStringOf(canonicalizePreparedDigestInput(input)); +const cfcLogger = getLogger("cfc", { enabled: false }); + +/** Hashes canonical preparation inputs and records the digest span. */ +export const preparedDigestFor = (input: PreparedDigestInput): string => { + const started = performance.now(); + try { + return hashStringOf(canonicalizePreparedDigestInput(input)); + } finally { + cfcLogger.time(started, "preparedDigestFor"); + } +}; diff --git a/packages/runner/src/cfc/prepare.ts b/packages/runner/src/cfc/prepare.ts index cd8ee1c310..57772ee845 100644 --- a/packages/runner/src/cfc/prepare.ts +++ b/packages/runner/src/cfc/prepare.ts @@ -35,6 +35,7 @@ import type { MemorySpace, URI } from "@commonfabric/memory/interface"; import { STREAM_ENTRIES_DOC_PREFIX } from "@commonfabric/memory/v2"; import { isArrayIndexPropertyName } from "@commonfabric/utils/arrays"; import { deepEqual } from "@commonfabric/utils/deep-equal"; +import { getLogger } from "@commonfabric/utils/logger"; import { stringTupleKey } from "@commonfabric/utils/string-tuple-key"; import { isObjectNotArray, isObjectOrArray } from "@commonfabric/utils/types"; @@ -2462,7 +2463,7 @@ const isReplacedMembershipEntry = ( // with the tx state sealed (getCfcState() is a read-only view) the only way // to exercise it is to hand deriveFlowJoin a state carrying a smuggled // entry directly. -export const deriveFlowJoin = ( +const deriveFlowJoinImpl = ( tx: IExtendedStorageTransaction, options?: { /** @@ -5545,7 +5546,7 @@ export const loadStoredCfcEnvelope = ( * A sink request can depend on any handler read, so the consumed set is * transaction-global (docs/specs/cfc-write-prefix-provenance.md §7.4). */ -export const collectConsumedLabel = ( +const collectConsumedLabelImpl = ( tx: IExtendedStorageTransaction, ): { confidentiality: readonly CfcConfClause[]; @@ -8089,3 +8090,25 @@ export const prepareBoundaryCommit = ( } return reasons; }; + +const cfcLogger = getLogger("cfc", { enabled: false }); + +/** Derives the transaction flow join and records its preparation span. */ +export const deriveFlowJoin: typeof deriveFlowJoinImpl = (tx, options) => { + const started = performance.now(); + try { + return deriveFlowJoinImpl(tx, options); + } finally { + cfcLogger.time(started, "deriveFlowJoin"); + } +}; + +/** Collects consumed labels and records its preparation span. */ +export const collectConsumedLabel: typeof collectConsumedLabelImpl = (tx) => { + const started = performance.now(); + try { + return collectConsumedLabelImpl(tx); + } finally { + cfcLogger.time(started, "collectConsumedLabel"); + } +}; diff --git a/packages/runner/src/runtime-presets.ts b/packages/runner/src/runtime-presets.ts index a4fe2e6230..2ac36fc248 100644 --- a/packages/runner/src/runtime-presets.ts +++ b/packages/runner/src/runtime-presets.ts @@ -62,8 +62,9 @@ * | | patternTest/unitTest (per-test laxer mode) and | * | | remoteClient/browserWorker (host-controlled | * | | rollout) | - * | cfcFlowLabels | core-default (off); remoteClient / browserWorker | - * | | delta (host-controlled rollout) | + * | cfcFlowLabels | core-default (off); patternTest override and | + * | | remoteClient / browserWorker host-controlled | + * | | rollout | * | cfcWriteFloor | core-default (off); remoteClient delta | * | | (host-controlled rollout) — flip in coreOptions | * | | when a first-party rollout begins | @@ -878,6 +879,9 @@ export interface PatternTestPresetParams extends CoreParams { /** Per-test laxer mode; defaults to the shared core pin. */ cfcEnforcementMode?: CfcEnforcementMode; + /** Per-test flow-label propagation; defaults to the shared core posture. */ + cfcFlowLabels?: CfcFlowLabelsMode; + /** Statement-coverage collector for `cf test` and the pattern harnesses. */ patternCoverage?: PatternCoverageCollector; @@ -1032,6 +1036,9 @@ export const runtimePresets = { ...(params.cfcEnforcementMode !== undefined ? { cfcEnforcementMode: params.cfcEnforcementMode } : {}), + ...(params.cfcFlowLabels !== undefined + ? { cfcFlowLabels: params.cfcFlowLabels } + : {}), ...(params.fetch !== undefined ? { fetch: params.fetch } : {}), ...(params.errorHandlers !== undefined ? { errorHandlers: params.errorHandlers } diff --git a/packages/runner/src/storage/extended-storage-transaction.ts b/packages/runner/src/storage/extended-storage-transaction.ts index 088d1bfceb..607bc4e085 100644 --- a/packages/runner/src/storage/extended-storage-transaction.ts +++ b/packages/runner/src/storage/extended-storage-transaction.ts @@ -2603,6 +2603,15 @@ export class ExtendedStorageTransaction implements IExtendedStorageTransaction { } prepareCfc(): string { + const started = performance.now(); + try { + return this.#prepareCfc(); + } finally { + logger.time(started, "prepareCfc"); + } + } + + #prepareCfc(): string { // Verification always runs. There is deliberately no caller-supplied input // override: the commit-time digest recheck only confirms the prepared input // matches real activity, so accepting an external input here would let a diff --git a/packages/cli/test/fixtures/cfc-flow-labels/mapped-render.test.tsx b/packages/cli/test/fixtures/cfc-flow-labels/mapped-render.test.tsx new file mode 100644 index 0000000000..b8ed93e870 --- /dev/null +++ b/packages/cli/test/fixtures/cfc-flow-labels/mapped-render.test.tsx @@ -0,0 +1,109 @@ +/** + * Labeled SQLite mapped-render scaling fixture. Each render demands the full + * mapped view at the named row count; seeding precedes the render intervals. + * Run the same file with --cfc-enforcement-mode disabled --cfc-flow-labels off + * and with --cfc-shell-posture, adding --verbose --stats-threshold 0 to both. + */ +import { + action, + assert, + pattern, + sqliteDatabase, + type SqliteDb, + table, + TESTS, + UI, + Writable, +} from "commonfabric"; + +interface Row { + id: number; + title: string; +} + +const MappedRows = pattern<{ db: SqliteDb; count: number }>(({ db, count }) => { + const query = db.query( + "SELECT id, title FROM messages ORDER BY id LIMIT ?", + { params: [count] }, + ); + return { + query, + [UI]:
{query.result?.map((row) =>

{row.title}

)}
, + }; +}); + +export default pattern(() => { + const db = sqliteDatabase({ + tables: { + messages: table({ + id: "integer primary key", + title: { + type: "string", + sqlType: "text", + ifc: { confidentiality: ["fixture-private"] }, + }, + }), + }, + }); + const seed = action(() => { + for (let i = 1; i <= 150; i++) { + db.exec("INSERT INTO messages (id, title) VALUES (?, ?)", [ + i, + `Message ${i}`, + ]); + } + }); + const small = MappedRows({ db, count: 11 }); + const medium = MappedRows({ db, count: 50 }); + const large = MappedRows({ db, count: 150 }); + + const copied = new Writable([]); + + return { + small, + copied, + [TESTS]: [ + { action: seed }, + { action: action(() => console.log("Mapped render N=11")) }, + { assertion: assert(() => small.query.result?.length === 11) }, + { render: small[UI] }, + { + action: action(() => { + console.log("Labeled copy N=11"); + copied.set((small.query.result ?? []).map((row: Row) => ({ + id: row.id, + title: row.title, + }))); + }), + }, + { assertion: assert(() => copied.get().length === 11) }, + { action: action(() => console.log("Mapped render N=50")) }, + { assertion: assert(() => medium.query.result?.length === 50) }, + { render: medium[UI] }, + { + action: action(() => { + console.log("Labeled copy N=50"); + copied.set((medium.query.result ?? []).map((row: Row) => ({ + id: row.id, + title: row.title, + }))); + }), + }, + { assertion: assert(() => copied.get().length === 50) }, + { action: action(() => console.log("Mapped render N=150")) }, + { assertion: assert(() => large.query.result?.length === 150) }, + { render: large[UI] }, + { + action: action(() => { + console.log("Labeled copy N=150"); + copied.set((large.query.result ?? []).map((row: Row) => ({ + id: row.id, + title: row.title, + }))); + }), + }, + { assertion: assert(() => copied.get().length === 150) }, + { settle: true }, + ], + }; +});