Files
orca/config/scripts/check-changed-selection.mjs
T
Neil 61337dfb33 chore(ratchet): gate event-loop-blocking calls in the daemon, PTY and orcad code (#34614)
* fix(daemon): read the PTY process table without blocking on terminal kill and resize

The terminal daemon force-killed a session by running ps and reading
/proc/<pid>/stat synchronously, and checked the resize foreground group
with execFileSync('ps'). A slow or wedged ps froze every terminal the
daemon serves for up to the ps timeout on each call.

Both paths now use the async process-table read (runProcess with a
timeout that kills the child). SubprocessHandle.forceKill may return a
promise; the termination controller awaits it so a refused signal still
releases the claim for a retry. A force-kill or SIGWINCH whose root
exits while ps runs signals nothing, since the captured groups could
already be recycled. ConPTY force-kill stays synchronous so dispose()
still sees the node-pty kill it must not repeat. The in-process
provider's app-quit sweep keeps the blocking variant. The ps dialect
and table readers move to posix-pty-process-table.ts.

Refs #34410
Refs #33870

* fix(daemon): address review on async PTY process-table reads

Guard only the root-pid fallback on exit so proven groups are still swept
after dispose, wait out at most one ps dialect probe, move the sync-launch
baseline entry with the table reader, and keep tests cross-platform.

Refs #34410
Refs #33870

* fix(daemon): keep the PTY open until an in-flight force-kill sweep finishes

A sweep whose root exits mid-read must not signal: the freed TTY may
belong to another terminal. Dispose therefore defers destroy() until the
sweep settles, so its hangup cannot end the root first.

Refs #34410
Refs #33870

* test(daemon): build the destroy-tracking PTY mock without a type assertion

Refs #34410
Refs #33870

* fix(pty): make the local graceful-deadline force-kill async and stub ps in tests

The in-process provider's graceful-deadline timer now uses the async
group sweep; the app-quit sweep and the root-signal path stay
synchronous. Tests that drove force-kill through fixture pids now stub
the async process-table read instead of querying the host, and a new
test pins that waiters do not chain behind a failed dialect probe.
Session ids are only read for rows on the root's terminal.

Refs #34410
Refs #33870

* fix(pty): hold PTY teardown until an in-flight force-kill sweep settles

Orphan cleanup could destroy a local PTY while the graceful-deadline
sweep was still reading the process table, neutralizing its fallback
kill and hanging up the root first. Both the daemon handle and the
local provider now defer teardown through PendingPtyGroupSweep.

Concurrent force-kill callers in the daemon now await the shared
in-flight request, so a joiner's physical-exit budget starts after the
signals are sent and it sees their rejection.

Refs #34410
Refs #33870

* fix(pty): let disposal and orphan cleanup join the in-flight force-kill sweep

Session disposal now joins the controller's shared force-kill request
instead of starting a second sweep. Local orphan cleanup decides its
escalation only after a pending graceful-deadline sweep settles, so a
refused sweep is escalated before the handle is released rather than
leaving the retry with a neutralized kill.

Refs #34410
Refs #33870

* fix(daemon): SIGKILL live PTY groups before a shutdown deadline exit

The old synchronous sweep sent its signals before the shutdown deadline
timer could run; the async sweep can still be reading the process table
when it fires. The deadline now SIGKILLs each live session's process
group synchronously before exiting (node-pty roots lead their own
group), falling back to the root pid when the group kill is refused.
POSIX only.

Refs #34410
Refs #33870

* chore(ratchet): gate event-loop-blocking calls in the daemon, PTY and orcad code

The terminal daemon and orcad serve every pane from one event loop, so a
synchronous child launch or file write there freezes all of them. A new
count ratchet tracks execFileSync/spawnSync/execSync, runProcessSync,
appendFileSync and writeSync per file under src/main/daemon,
src/main/pty, src/main/runtime and src/main/orcad; counts may only go
down. It reuses the sync-spawn ratchet's import-aware call collector,
now extracted to gated-call-use-collector.mjs, so aliases, namespace
imports and re-exports count and same-named methods on other objects
do not.

Refs #34410
Refs #33870
2026-10-27 19:05:34 -07:00

249 lines
9.5 KiB
JavaScript

import { affectsLocalizationExtraction } from './localization-extraction-change-scope.mjs'
import { classifyPrJobs, isDocsOnlyPath } from './pr-code-change-scope.mjs'
import { listRatchetScripts } from './run-ratchets.mjs'
// Any of these can change what every check reports, so they select all of them.
const SELECT_ALL_FILES = new Set(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
const SELECT_ALL_PREFIXES = ['config/scripts/check-changed', 'config/scripts/pr-code-change-scope']
// Mirrors forceRerunTriggers in config/vitest.config.ts: one such path in a `related` run selects
// every test. That full run is CI's job; locally these paths run only their own colocated tests.
const FULL_SUITE_TRIGGER =
/(?:^|\/)(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|vitest\.config\.[^/]+|vite\.config\.[^/]+)$|(?:^|\/)(?:config|patches)\/|^src\/packages\//
// Only module-graph files can make Vitest's `related` selection meaningful.
const IMPORTABLE = /\.(?:[cm]?[jt]sx?|json|css|scss|svg|html|wasm|ya?ml|txt|sql)$/
const UNIT_TEST_FILE = /\.test\.[cm]?[jt]sx?$/
const SOURCE_ROOTS = ['src/', 'mobile/', 'tests/', 'config/']
const TEST_FILE = /\.(?:test|spec|e2e|bench)\.[cm]?[jt]sx?$/
const LOCALES = 'src/renderer/src/i18n/'
// Ratchets default to SOURCE_ROOTS; list narrower inputs only where a scan is clearly scoped.
const RATCHET_INPUTS = {
'check-daemon-blocking-call-ratchet.mjs': ['src/main/'],
'check-e2e-typecheck-ratchet.mjs': ['tests/', 'config/tsconfig.e2e.json'],
'check-focus-read-ratchet.mjs': ['src/renderer/src/', 'src/shared/'],
'check-host-list-render-ratchet.mjs': ['src/renderer/src/'],
'check-id-only-store-write-ratchet.mjs': ['src/main/persistence/'],
'check-layout-writer-ratchets.mjs': ['src/main/', 'src/renderer/src/'],
'check-main-sync-spawn-ratchet.mjs': ['src/main/'],
'check-repo-host-read-ratchet.mjs': ['src/cli/', 'src/main/', 'src/shared/'],
'check-runtime-electron-ratchet.mjs': ['src/main/', 'src/cli/', 'src/shared/'],
'check-unproven-pty-retirement-ratchet.mjs': ['src/main/'],
'check-workspace-record-read-ratchet.mjs': ['src/renderer/src/'],
'check-worktree-id-composer-ratchet.mjs': ['src/', 'mobile/src/']
}
// The lint-only verifiers from package.json `lint`, keyed to the inputs they read. A check also
// runs when its own script changes.
export const VERIFY_CHECKS = [
{
name: 'rpc params catalog',
args: ['config/scripts/generate-rpc-params-catalog.mjs', '--check'],
inputs: ['src/main/runtime/rpc/', 'src/shared/']
},
{
name: 'acp protocol',
args: ['config/scripts/acp/generate-protocol.mjs', '--check'],
inputs: ['src/main/acp/generated/', 'config/scripts/acp/']
},
{
name: 'bundled skill guides',
args: ['config/scripts/generate-bundled-skill-guides.mjs', '--check'],
inputs: ['skills/', 'config/scripts/skill-stub-composition']
},
{
name: 'skill bundle manifest',
args: ['config/scripts/generate-skill-bundle-manifest.mjs'],
inputs: ['skills/', 'resources/skills/']
},
{
name: 'localization catalogs',
runtime: 'bun',
args: ['config/scripts/verify-localization-catalogs.mjs'],
inputs: ['src/renderer/', 'src/shared/', LOCALES, 'config/scripts/verify-localization-catalog']
},
{
name: 'localization coverage',
runtime: 'bun',
args: ['config/scripts/audit-localization-coverage.mjs', '--check'],
inputs: ['src/renderer/src/', LOCALES]
},
{
// CI's own predicate, so local and CI select this check identically.
name: 'localization extraction',
args: ['config/scripts/verify-localization-extraction.mjs'],
matches: (files) => affectsLocalizationExtraction(files)
},
{
name: 'reliability gates',
args: ['config/scripts/check-reliability-gates.mjs'],
inputs: ['config/reliability-gates.jsonc']
},
{
name: 'node runtime pin',
args: ['config/scripts/check-node-runtime-pin.mjs'],
inputs: ['src/shared/node-runtime-pin', 'config/scripts/node-dist-archive-name']
},
{
name: 'test file runners',
args: ['config/scripts/check-test-file-runners.mjs'],
inputs: [
'config/scripts/ci-unit-files.mjs',
'tests/playwright.config.ts',
'mobile/vitest.config.ts'
],
matches: (files) =>
files.some((file) => TEST_FILE.test(file) || /vitest[^/]*\.config/.test(file))
},
{
name: 'mobile shared dependency versions',
args: ['config/scripts/check-mobile-shared-dependency-versions.mjs'],
inputs: ['mobile/package.json', 'mobile/pnpm-lock.yaml']
},
{
name: 'readme local links',
args: ['config/scripts/check-readme-local-links.mjs'],
inputs: ['README.md', 'docs/readme/']
},
{
name: 'import boundaries',
runtime: 'bun',
args: ['config/scripts/check-import-boundaries.mjs'],
inputs: [...SOURCE_ROOTS, 'config/process-host-direct-import-baseline.txt']
},
{
name: 'dead design-system classes',
oxlint: ['--config', 'config/oxlint-dead-classes.json', 'src/renderer'],
inputs: ['src/renderer/', 'config/oxlint-dead-classes.json']
}
]
// Projects of run-typecheck-projects-in-parallel.mjs and the source trees whose types reach them.
const TYPECHECK_INPUTS = [
{
config: 'config/tsconfig.node.json',
inputs: [
'src/main/',
'src/preload/',
'src/shared/',
'src/renderer/',
'src/cli/',
'src/wsl-guest/',
'config/'
]
},
{
config: 'config/tsconfig.tc.web.json',
inputs: ['src/renderer/', 'src/preload/', 'src/shared/', 'src/main/']
},
{ config: 'config/tsconfig.tc.cli.json', inputs: ['src/cli/', 'src/shared/', 'src/main/'] },
{
config: 'src/packages/process-host/tsconfig.test.json',
inputs: ['src/packages/process-host/']
}
]
const TYPECHECK_ALL_PREFIXES = ['src/packages/', 'src/types/', 'config/tsconfig', 'tsconfig.json']
// JSON reaches the type graph only through src/ imports (resolveJsonModule).
const isTypedSource = (file) =>
/\.[cm]?tsx?$/.test(file) || (file.startsWith('src/') && file.endsWith('.json'))
function matchesPrefix(file, prefixes) {
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
// A script's stem also matches its tests and sibling modules (`verify-localization-catalog*`).
function ownScriptStem(check) {
return check.args?.[0].replace(/\.mjs$/, '')
}
export function selectTypecheckProjects(files) {
if (
files.some((file) => SELECT_ALL_FILES.has(file) || matchesPrefix(file, TYPECHECK_ALL_PREFIXES))
) {
return TYPECHECK_INPUTS.map(({ config }) => config)
}
const typed = files.filter(isTypedSource)
return TYPECHECK_INPUTS.filter(({ inputs }) =>
typed.some((file) => matchesPrefix(file, inputs))
).map(({ config }) => config)
}
// Shared analysis modules select every ratchet built on them.
const RATCHET_SHARED_MODULES = {
'config/scripts/gated-call-use-collector.mjs': [
'check-daemon-blocking-call-ratchet.mjs',
'check-main-sync-spawn-ratchet.mjs'
]
}
function listRatchetChecks(scriptsDir) {
const scripts = new Set(listRatchetScripts(scriptsDir))
// `pnpm lint` runs these although CI checks them in their own steps outside run-ratchets.mjs.
scripts.add('check-main-sync-spawn-ratchet.mjs')
scripts.add('check-flaky-test-pattern-ratchet.mjs')
return [...scripts].sort().map((script) => ({
name: script.replace(/^check-|\.mjs$/g, ''),
args: [`config/scripts/${script}`],
inputs: RATCHET_INPUTS[script] ?? SOURCE_ROOTS,
matches: (files) =>
files.some(
(file) =>
/^config\/[^/]+-baseline\.txt$/.test(file) ||
RATCHET_SHARED_MODULES[file]?.includes(script)
)
}))
}
function isSelected(check, files) {
const stem = ownScriptStem(check)
return (
(stem !== undefined && files.some((file) => file.startsWith(stem))) ||
files.some((file) => matchesPrefix(file, check.inputs ?? [])) ||
(check.matches?.(files) ?? false)
)
}
// `related` paths go through Vitest's import graph; `colocated` are explicit test files, either
// changed tests under a trigger path or the `<stem>.test.*` / `<stem>.*.test.*` siblings of one.
export function splitUnitTestInputs(files, listDirectory = () => []) {
const related = files.filter(
(file) => IMPORTABLE.test(file) && !FULL_SUITE_TRIGGER.test(file) && !isDocsOnlyPath(file)
)
const colocated = new Set()
for (const file of files.filter((candidate) => FULL_SUITE_TRIGGER.test(candidate))) {
if (UNIT_TEST_FILE.test(file)) {
colocated.add(file)
continue
}
const slash = file.lastIndexOf('/')
const directory = file.slice(0, slash + 1)
const stem = file.slice(slash + 1).replace(/\.[^.]+$/, '')
for (const name of listDirectory(directory).map(String)) {
if (UNIT_TEST_FILE.test(name) && name.startsWith(`${stem}.`)) {
colocated.add(directory + name)
}
}
}
return { related, colocated: [...colocated].sort() }
}
/** Decides which checks a set of changed paths needs; CI's classifier gates the expensive jobs. */
export function planChangedChecks(files, { scriptsDir, listDirectory }) {
const jobs = classifyPrJobs(files)
const selectAll = files.some(
(file) => SELECT_ALL_FILES.has(file) || matchesPrefix(file, SELECT_ALL_PREFIXES)
)
const checks = [...listRatchetChecks(scriptsDir), ...VERIFY_CHECKS]
return {
staticAnalysis: jobs.static_analysis,
typecheckProjects: jobs.typecheck ? selectTypecheckProjects(files) : [],
unitTests: jobs.test
? splitUnitTestInputs(files, listDirectory)
: { related: [], colocated: [] },
checks: jobs.static_analysis
? checks.filter((check) => selectAll || isSelected(check, files))
: []
}
}