diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index d5c8134934b..0d3bf3cf398 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -433,13 +433,13 @@ jobs: # result's generation is authoritative either way. ISOLATE_RESULT="$(node dev/scripts/prepare-relay-production-capacity-canary.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ - --cell-id "${TARGET_CELL_ID}" --mode isolate)" + --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode isolate)" echo "${ISOLATE_RESULT}" ISOLATE_GENERATION="$(jq -er '.generation' <<< "${ISOLATE_RESULT}")" echo "SELECTOR_GENERATION_AFTER_ISOLATE=${ISOLATE_GENERATION}" >> "${GITHUB_ENV}" node dev/scripts/prepare-relay-production-capacity-canary.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ - --cell-id "${TARGET_CELL_ID}" --mode drain + --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode drain node dev/scripts/verify-relay-capacity-transition.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ --cell-id "${TARGET_CELL_ID}" --hard-cap "${EXPECTED_HARD_CAP}" \ @@ -613,7 +613,7 @@ jobs: echo "MUTATION_STARTED=true" >> "${GITHUB_ENV}" ACTIVATE_RESULT="$(node dev/scripts/prepare-relay-production-capacity-canary.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ - --cell-id "${TARGET_CELL_ID}" --mode activate)" + --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode activate)" echo "${ACTIVATE_RESULT}" SELECTOR_GENERATION_AFTER_ACTIVATE="$(jq -er '.generation' \ <<< "${ACTIVATE_RESULT}")" @@ -652,7 +652,7 @@ jobs: test "${MUTATION_STARTED:-false}" = true || exit 0 ISOLATE_RESULT="$(node dev/scripts/prepare-relay-production-capacity-canary.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ - --cell-id "${TARGET_CELL_ID}" --mode isolate)" + --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode isolate)" echo "${ISOLATE_RESULT}" # The isolate result carries the authoritative post-isolate generation; # fixed offsets are wrong whenever an earlier isolate was a no-op. diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs index e5c18237453..7967d164e4b 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.mjs @@ -6,6 +6,7 @@ import { membershipWithStates, selectorCellState } from './relay-admission-selector.mjs' +import { SAME_CAP_CELLS } from './relay-production-same-cap-wave.mjs' const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export const PRODUCTION_CAPACITY_CELL_IDS = [ @@ -31,6 +32,9 @@ function cellOrigin(cellId) { return `https://${cellId.slice('production-gce-'.length)}.relay.onorca.dev` } +// The same-cap roll covers the Asia cells the US-only capacity rollout never touches. +const APPROVED_CELL_LISTS = { 'same-cap': SAME_CAP_CELLS } + export function parseProductionCapacityCellArguments(argv) { const values = {} for (let index = 0; index < argv.length; index += 2) { @@ -42,8 +46,15 @@ export function parseProductionCapacityCellArguments(argv) { if (!['isolate', 'drain', 'activate'].includes(values.mode)) { throw new Error('--mode must be isolate, drain, or activate') } + const approvedList = values['approved-cells'] + if (approvedList !== undefined && !APPROVED_CELL_LISTS[approvedList]) { + throw new Error('--approved-cells is not a known allowlist') + } + const approvedCellIds = approvedList === undefined + ? PRODUCTION_CAPACITY_CELL_IDS + : APPROVED_CELL_LISTS[approvedList] const cellId = values['cell-id'] - if (!PRODUCTION_CAPACITY_CELL_IDS.includes(cellId)) { + if (!approvedCellIds.includes(cellId)) { throw new Error('production capacity target is not approved') } const expectedCellOrigin = cellOrigin(cellId) diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index ff397802860..274a60d2198 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -104,6 +104,47 @@ describe('production Relay capacity cell admission', () => { '--cell-id', 'production-gce-c7', '--mode', 'isolate' ]), /origin is not exact/) + assert.throws(() => parseProductionCapacityCellArguments([ + '--director-origin', 'https://relay.onorca.dev', + '--cell-origin', 'https://c27.relay.onorca.dev', + '--cell-id', 'production-gce-c27', + '--mode', 'isolate' + ]), /not approved/) + }) + + it('admits the same-cap Asia cells only under the same-cap allowlist', () => { + for (const cellId of ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']) { + const hostname = cellId.slice('production-gce-'.length) + assert.deepEqual(parseProductionCapacityCellArguments([ + '--director-origin', 'https://relay.onorca.dev', + '--cell-origin', `https://${hostname}.relay.onorca.dev`, + '--cell-id', cellId, + '--approved-cells', 'same-cap', + '--mode', 'isolate' + ]), { + directorOrigin: 'https://relay.onorca.dev', + cellOrigin: `https://${hostname}.relay.onorca.dev`, + cellId, + mode: 'isolate' + }) + } + for (const cellId of ['production-gce-c17', 'production-gce-c18', 'production-gce-c30']) { + const hostname = cellId.slice('production-gce-'.length) + assert.throws(() => parseProductionCapacityCellArguments([ + '--director-origin', 'https://relay.onorca.dev', + '--cell-origin', `https://${hostname}.relay.onorca.dev`, + '--cell-id', cellId, + '--approved-cells', 'same-cap', + '--mode', 'isolate' + ]), /not approved/) + } + assert.throws(() => parseProductionCapacityCellArguments([ + '--director-origin', 'https://relay.onorca.dev', + '--cell-origin', 'https://c27.relay.onorca.dev', + '--cell-id', 'production-gce-c27', + '--approved-cells', 'every-cell', + '--mode', 'isolate' + ]), /not a known allowlist/) }) it('isolates only the selected cell without depending on its runtime', async () => { diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs new file mode 100644 index 00000000000..714a1ee53e3 --- /dev/null +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { describe, it } from 'node:test' +import { parseProductionCapacityCellArguments } from './prepare-relay-production-capacity-canary.mjs' +import { SAME_CAP_CELLS } from './relay-production-same-cap-wave.mjs' +import { readRelayWorkflow } from './relay-repository.mjs' + +const workflow = readRelayWorkflow('deploy-relay-production-same-cap-job.yml') +const capacityWorkflow = readRelayWorkflow('deploy-relay-production-capacity-job.yml') + +function hostname(cellId) { + return cellId.slice('production-gce-'.length) +} + +// The job resolves cap and region from the cell id before any admin call; run that block alone. +function resolveCellShape(cellId) { + const start = workflow.indexOf(' TARGET_HOSTNAME="${TARGET_CELL_ID#production-gce-}"') + assert.notEqual(start, -1, 'the same-cap cell shape block is missing') + const end = workflow.indexOf('\n esac\n', start) + assert.notEqual(end, -1, 'the same-cap cell shape block has no esac') + const script = workflow.slice(start, end + '\n esac'.length).replace(/^ {10}/gm, '') + return spawnSync('bash', [ + '-euo', + 'pipefail', + '-c', + `${script}\necho "\${EXPECTED_REGION} \${EXPECTED_HARD_CAP}"` + ], { env: { ...process.env, TARGET_CELL_ID: cellId }, encoding: 'utf8' }) +} + +describe('same-cap roll scripts accept every same-cap cell', () => { + it('parses every wave cell through the same-cap canary allowlist', () => { + for (const cellId of SAME_CAP_CELLS) { + for (const mode of ['isolate', 'drain', 'activate']) { + assert.deepEqual(parseProductionCapacityCellArguments([ + '--director-origin', 'https://relay.onorca.dev', + '--cell-origin', `https://${hostname(cellId)}.relay.onorca.dev`, + '--cell-id', cellId, + '--approved-cells', 'same-cap', + '--mode', mode + ]), { + directorOrigin: 'https://relay.onorca.dev', + cellOrigin: `https://${hostname(cellId)}.relay.onorca.dev`, + cellId, + mode + }) + } + } + }) + + it('resolves a cap and region for every wave cell and refuses anything else', () => { + for (const cellId of SAME_CAP_CELLS) { + const resolved = resolveCellShape(cellId) + assert.equal(resolved.status, 0, `${cellId}: ${resolved.stderr}`) + assert.match(resolved.stdout.trim(), /^(us-central1 1000|asia-east2 3000)$/) + } + assert.equal(resolveCellShape('production-gce-c17').status, 1) + assert.equal(resolveCellShape('production-gce-c30').status, 1) + }) + + it('passes the same-cap allowlist on every canary invocation the job runs', () => { + const invocations = workflow.split('prepare-relay-production-capacity-canary.mjs').slice(1) + assert.equal(invocations.length, 4) + for (const invocation of invocations) { + const lines = invocation.split('\n') + const end = lines.findIndex((line) => !line.endsWith('\\')) + const call = lines.slice(0, end + 1).join(' ') + assert.match(call, /--approved-cells same-cap/) + assert.match(call, /--mode (isolate|drain|activate)/) + } + }) + + it('leaves the US-only capacity job on the default allowlist', () => { + assert.doesNotMatch(capacityWorkflow, /--approved-cells/) + }) +}) diff --git a/cloud/package.json b/cloud/package.json index 788b6ea2629..62dbadc7455 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,7 +21,7 @@ "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { diff --git a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs index 5ececab3298..128627676c4 100644 --- a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs +++ b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs @@ -57,7 +57,7 @@ const { POLL_TIER_INTERVAL_MS } = await import( path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-cadence.ts') ) const { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } = await import( - path.join(ROOT, 'src/shared/process-table-snapshot-timing.ts') + path.join(ROOT, 'src/shared/process-table-snapshot-reader.ts') ) // Pre-change: independent ±10% jitter per pane, re-rolled on every reschedule. diff --git a/src/main/git/worktree-base-divergence-real-git.test.ts b/src/main/git/worktree-base-divergence-real-git.test.ts index 377b63b48f9..4aa1734c792 100644 --- a/src/main/git/worktree-base-divergence-real-git.test.ts +++ b/src/main/git/worktree-base-divergence-real-git.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { measureRetargetDivergence, RETARGET_MAX_COMMIT_DIVERGENCE @@ -10,8 +11,13 @@ import { const tempRoots: string[] = [] +// Why the maintenance suppression: `git commit` detaches `git maintenance run --auto`, and its +// commit-graph task arms once a fixture crosses 100 new commits — which the cap-sized histories +// below always do. That detached process keeps writing `.git/objects/info/commit-graphs` after the +// synchronous exec has returned, so it re-creates entries under a `.git/objects` the temp-root +// teardown is midway through deleting, and the recursive remove dies with ENOTEMPTY. function git(cwd: string, args: string[]): string { - return execFileSync('git', args, { + return execFileSync('git', [...GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS, ...args], { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts index fb85fb0f212..fffbb94de90 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts @@ -1,6 +1,5 @@ -import { build } from 'esbuild' import { describe, expect, it } from 'vitest' -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-timing' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-reader' import { POLL_TIER_INTERVAL_MS } from './agent-completion-poll-cadence' import { nextCadenceInspectionDelayMs } from './agent-completion-poll-interval' @@ -15,18 +14,6 @@ describe('nextCadenceInspectionDelayMs', () => { now }) - it('keeps its production import graph browser-only', async () => { - await expect( - build({ - bundle: true, - entryPoints: [`${import.meta.dirname}/agent-completion-poll-interval.ts`], - logLevel: 'silent', - platform: 'browser', - write: false - }) - ).resolves.toMatchObject({ errors: [] }) - }) - it('walks panes that scheduled at different moments onto one shared deadline', () => { // Why this matters: the inspection queue collapses shared-observation tasks enqueued in the // same tick onto one process-table capture, so a shared deadline is one `ps` for all panes. diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts index 79c1c6bd439..0de8632f552 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts @@ -1,4 +1,6 @@ -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-timing' +// Why not the sibling reader that re-exports this: it imports `node:child_process`, which the +// renderer cannot load — reaching it blanks the window at module evaluation. +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot' /** * Picks the delay until a pane's next cadence inspection. diff --git a/src/renderer/src/renderer-node-builtin-boundary.test.ts b/src/renderer/src/renderer-node-builtin-boundary.test.ts new file mode 100644 index 00000000000..1cc28bd73af --- /dev/null +++ b/src/renderer/src/renderer-node-builtin-boundary.test.ts @@ -0,0 +1,111 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * The renderer runs sandboxed with contextIsolation: `node:*` builtins do not resolve and even a + * bare `process` read throws. A module that reaches one is not a degraded feature — the chunk + * fails at evaluation, React never mounts, and the window stays blank with `workspaceSessionReady` + * stuck false (#18742 did exactly this by importing one constant out of a `node:child_process` + * module). Bundling hides it: the offending module can sit in a shared chunk far from the import + * that pulled it in. + * + * So walk the import graph from every renderer entry — lazy routes included, since a `node:` + * builtin behind one is just a blank route instead of a blank app — and refuse any builtin. + */ +const RENDERER_SRC = import.meta.dirname +const REPO_SRC = path.resolve(RENDERER_SRC, '../..') +const ENTRIES = ['main.tsx', 'popout.tsx', 'web/main.tsx'] +const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'] + +/** `import`/`export ... from` and `import(...)` specifiers, minus type-only ones, which erase. */ +function collectValueImportSpecifiers(source: string): string[] { + const specifiers: string[] = [] + const pattern = + /(?:^|[\s;}])(?:import|export)(\s+type\s|\s*\{[^}]*\}|[^'"]*?)?\s*from\s*['"]([^'"]+)['"]|(?:^|[\s;}])import\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g + for (const match of source.matchAll(pattern)) { + const clause = match[1] ?? '' + const specifier = match[2] ?? match[3] ?? match[4] + if (!specifier || /^\s*type\s/.test(clause)) { + continue + } + // A brace clause whose every binding is `type`-prefixed also erases entirely. + const bindings = clause.trim().startsWith('{') ? clause.trim().slice(1, -1).split(',') : null + if (bindings && bindings.some((b) => b.trim()) && bindings.every((b) => /^\s*type\s/.test(b))) { + continue + } + specifiers.push(specifier) + } + return specifiers +} + +function resolveModule(specifier: string, fromFile: string): string | null { + let base: string + if (specifier.startsWith('@renderer/')) { + base = path.join(RENDERER_SRC, specifier.slice('@renderer/'.length)) + } else if (specifier.startsWith('@/')) { + base = path.join(RENDERER_SRC, specifier.slice(2)) + } else if (specifier.startsWith('.')) { + base = path.resolve(path.dirname(fromFile), specifier) + } else { + // Bare package specifiers are npm dependencies, not first-party source. + return null + } + for (const candidate of [ + ...EXTENSIONS.map((ext) => `${base}${ext}`), + ...EXTENSIONS.map((ext) => path.join(base, `index${ext}`)) + ]) { + if (existsSync(candidate)) { + return candidate + } + } + return null +} + +function walkRendererImportGraph(): Map { + /** file -> the chain of first-party importers that reached it, entry first. */ + const pathToFile = new Map() + const queue: string[] = [] + for (const entry of ENTRIES) { + const file = path.join(RENDERER_SRC, entry) + pathToFile.set(file, [file]) + queue.push(file) + } + while (queue.length > 0) { + const file = queue.shift() as string + const chain = pathToFile.get(file) as string[] + for (const specifier of collectValueImportSpecifiers(readFileSync(file, 'utf8'))) { + const resolved = resolveModule(specifier, file) + if (!resolved || pathToFile.has(resolved)) { + continue + } + pathToFile.set(resolved, [...chain, resolved]) + queue.push(resolved) + } + } + return pathToFile +} + +describe('renderer node-builtin boundary', () => { + it('reaches no module that imports a node: builtin', () => { + const graph = walkRendererImportGraph() + const offenders: string[] = [] + for (const [file, chain] of graph) { + const builtins = collectValueImportSpecifiers(readFileSync(file, 'utf8')).filter( + (specifier) => specifier.startsWith('node:') + ) + if (builtins.length === 0) { + continue + } + const relativeChain = chain.map((step) => path.relative(REPO_SRC, step)).join('\n -> ') + offenders.push(`${builtins.join(', ')} via\n ${relativeChain}`) + } + expect(offenders.join('\n\n')).toBe('') + }) + + it('walks a real graph, so an empty offender list means something', () => { + const graph = walkRendererImportGraph() + expect(graph.size).toBeGreaterThan(3_000) + expect(graph.has(path.join(REPO_SRC, 'shared/process-table-snapshot.ts'))).toBe(true) + }) +}) diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index 764fb89f205..2215a1d2af7 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -2,8 +2,8 @@ import { execFile as execFileCb } from 'node:child_process' import { promisify } from 'node:util' import { readLinuxProcessStartTimes } from './linux-process-start-times' import { withEvidenceBudget } from './process-table-evidence-budget' -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from './process-table-snapshot-timing' import { + PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS, PS_ARGS, PS_MAX_BUFFER_BYTES, ProcessTableCaptureError, @@ -12,13 +12,12 @@ import { type ProcessTableRow } from './process-table-snapshot' -export { PS_ARGS, PS_MAX_BUFFER_BYTES } export { parseLinuxProcStatStartTime } from './linux-process-start-times' export { PROCESS_TABLE_EVIDENCE_BUDGET_MS, withEvidenceBudget } from './process-table-evidence-budget' -export { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from './process-table-snapshot-timing' +export { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS, PS_ARGS, PS_MAX_BUFFER_BYTES } const execFile = promisify(execFileCb) diff --git a/src/shared/process-table-snapshot-timing.ts b/src/shared/process-table-snapshot-timing.ts deleted file mode 100644 index eaa3621728c..00000000000 --- a/src/shared/process-table-snapshot-timing.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** How long a completed shared process-table snapshot remains reusable, and the maximum cadence pull-forward. */ -export const PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS = 500 diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index 0acc6f6f9e9..3b3236079c4 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -13,9 +13,14 @@ export type ProcessTableRow = { command: string } +// Why guarded: this module is the renderer-safe half of the process-table pair, and the renderer +// runs sandboxed with contextIsolation, where a bare `process` read throws at module evaluation +// and takes the whole chunk — and the app — down with it. Only hosts ever run these argv. +const HOST_IS_DARWIN = typeof process !== 'undefined' && process.platform === 'darwin' + /** Columns used by the evidence reader. Keep command last so its spaces survive parsing. */ export const PS_ARGS = ( - process.platform === 'darwin' + HOST_IS_DARWIN ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command='] : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command='] ) as readonly string[] @@ -28,7 +33,7 @@ export const PS_ARGS = ( * marker comes from `/proc//stat` for the pane subtree only. */ export const CHEAP_PS_ARGS = ( - process.platform === 'darwin' + HOST_IS_DARWIN ? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart='] : ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat='] ) as readonly string[] @@ -80,6 +85,14 @@ export function parseCheapProcessTableRows(stdout: string): CheapProcessTableRow // "unverifiable". Matches the sibling reader in pty-descendant-termination.ts. export const PS_MAX_BUFFER_BYTES = 32 * 1024 * 1024 +/** How much older than its own await a TTL-cached capture may be, on top of the capture's own + * duration. Reported ages carry both, so this alone is not the staleness bound. + * + * Why here and not beside the reader that applies it: the renderer's cadence scheduler pulls a + * pane's next poll forward by at most this much, and the reader is a `node:child_process` module + * the renderer must never reach. */ +export const PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS = 500 + /** * Parse legacy or evidence-shaped `ps` output into rows. Tolerates CRLF so a * snapshot parsed on any host stays correct; `command` (last field) keeps its