merge latest main for PR 8279 validation

# Conflicts:
#	config/scripts/build-relay.mjs
This commit is contained in:
Brennan Benson
2026-07-12 22:08:09 -07:00
46 changed files with 2392 additions and 295 deletions
+6
View File
@@ -871,6 +871,12 @@ jobs:
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
+6
View File
@@ -127,6 +127,12 @@ jobs:
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (macOS)
uses: nick-fields/retry@v4
with:
+79 -10
View File
@@ -4213,7 +4213,7 @@
},
{
"id": "runtime-files.watcher-process-isolation",
"title": "Runtime file watcher faults stay process-isolated and recover without host starvation",
"title": "Runtime and SSH relay watcher faults stay process-isolated without disrupting host services",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
@@ -4222,7 +4222,8 @@
"headless orca serve",
"paired file explorer",
"runtime editor external-change detection",
"desktop filesystem watcher"
"desktop filesystem watcher",
"SSH relay filesystem watcher and live PTYs"
],
"platforms": [
"macos",
@@ -4242,19 +4243,22 @@
"remote-runtime",
"ssh"
],
"coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. Windows keeps the existing fs.watch runtime path; SSH shared-watch setup uses registration-owned cancellation so one caller cannot cancel another caller's watch.",
"coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH now bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/5308",
"https://github.com/stablyai/orca/issues/7547",
"https://github.com/stablyai/orca/issues/8212"
],
"invariant": "Local runtime roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, directory metadata work stays bounded to eight concurrent operations per child, destructive desktop cleanup physically aborts pending native setup, and terminal renderer watches are evicted before error callbacks can retry.",
"oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Synchronously retry from a renderer terminal-error callback and require a fresh stream that survives the old stream's end. Run eight simultaneous metadata batches in one child and assert no more than eight stats are active there. Drive a real built child through the source supervisor, kill it with SIGSEGV, and require automatic resubscription plus a post-crash event while the harness parent remains alive.",
"invariant": "Local runtime and SSH relay roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.",
"oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts",
"pnpm run build:electron-vite",
"node config/scripts/runtime-file-watcher-fault-harness.mjs",
"ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs"
"ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs",
"pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs",
"pnpm run build:relay",
"node config/scripts/relay-watcher-fault-harness.mjs"
],
"testFiles": [
"src/main/ipc/parcel-watcher-process.test.ts",
@@ -4267,7 +4271,13 @@
"src/main/runtime/rpc/methods/files.test.ts",
"src/main/providers/ssh-filesystem-provider.test.ts",
"src/renderer/src/runtime/runtime-file-client.test.ts",
"config/scripts/runtime-file-watcher-fault-harness.mjs"
"config/scripts/runtime-file-watcher-fault-harness.mjs",
"src/relay/relay-filesystem-watch-registry.test.ts",
"src/relay/fs-handler.test.ts",
"src/relay/subprocess.test.ts",
"src/main/ssh/ssh-relay-versioned-install.test.ts",
"config/scripts/package-electron-runtime-contract.test.mjs",
"config/scripts/relay-watcher-fault-harness.mjs"
],
"assertionRefs": [
{
@@ -4277,7 +4287,8 @@
"IPC disconnect recovery restores every existing root before accepting new setup",
"queued abort removes one request without restarting healthy roots, while active abort or crawl-scoped timeout restores the shard",
"a crash-resubscribe crawl deadline ends only the stuck root and tears down its shard process",
"missing built entries fail closed and unavailable canary storage remains optional"
"missing built entries fail closed and unavailable canary storage remains optional",
"fault-harness PID reporting cannot clobber an existing filesystem path"
]
},
{
@@ -4348,6 +4359,39 @@
"a later same-root listener opens a fresh server subscription instead of reusing a dead stream"
]
},
{
"file": "src/relay/relay-filesystem-watch-registry.test.ts",
"assertions": [
"replacement emits overflow before later ordered events",
"terminal shard failure preserves same-root clients through recovery",
"pending setup aborts only after the last same-root client leaves",
"a missing child fails closed even if the relay inherits a VITEST environment"
]
},
{
"file": "src/relay/fs-handler.test.ts",
"assertions": [
"shared watches retain the twenty-root budget and release after every client leaves"
]
},
{
"file": "src/relay/subprocess.test.ts",
"assertions": [
"the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child"
]
},
{
"file": "src/main/ssh/ssh-relay-versioned-install.test.ts",
"assertions": [
"remote completeness requires relay.js, relay-watcher.js, and the install sentinel"
]
},
{
"file": "config/scripts/package-electron-runtime-contract.test.mjs",
"assertions": [
"every release platform packages and gates the hashed watcher child"
]
},
{
"file": "config/scripts/runtime-file-watcher-fault-harness.mjs",
"assertions": [
@@ -4355,17 +4399,42 @@
"the harness parent survives watcher-child SIGSEGV",
"the real supervisor automatically replaces the child, resubscribes, and delivers a post-crash event"
]
},
{
"file": "config/scripts/relay-watcher-fault-harness.mjs",
"assertions": [
"the built relay and existing PTY survive watcher-child fault injection",
"replacement emits overflow and a precise post-crash event"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs",
"result": "passed",
"durationSeconds": 4.5,
"summary": "Five relay registry, handler, bundled subprocess, install, and package contract files passed with 114 tests."
},
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "node config/scripts/relay-watcher-fault-harness.mjs",
"result": "passed",
"durationSeconds": 0.61,
"summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child."
},
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts",
"result": "passed",
"durationSeconds": 1.73,
"summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 222 tests."
"durationSeconds": 1.7,
"summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 223 tests."
},
{
"date": "2026-07-12",
@@ -76,9 +76,11 @@ describe('packaged Windows SSH relay node-pty patch', () => {
for (const platform of ['win32-x64', 'win32-arm64']) {
const relayDir = join(projectRoot, 'out', 'relay', platform)
const relayContent = readFileSync(join(relayDir, 'relay.js'))
const watcherContent = readFileSync(join(relayDir, 'relay-watcher.js'))
const patchContent = readFileSync(join(relayDir, patchName))
const expectedHash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(patchContent)
.digest('hex')
.slice(0, 12)
+24 -6
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* Bundle the relay daemon into a single relay.js file per platform.
* Bundle the relay daemon and its crash-isolated watcher child per platform.
*
* The relay runs on remote hosts via `node relay.js`, so it must be a
* self-contained CommonJS bundle with no external dependencies beyond
* The relay runs on remote hosts via `node relay.js`, so both outputs use
* self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
@@ -17,6 +17,7 @@ const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const WINDOWS_NODE_PTY_PATCH_SOURCE = join(
ROOT,
'config',
@@ -49,7 +50,22 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
@@ -58,9 +74,11 @@ for (const platform of PLATFORMS) {
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped (common during development).
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
// and the Windows-only patch so every runtime dependency deploys together.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const artifactHash = createHash('sha256').update(relayContent)
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const artifactHash = createHash('sha256').update(relayContent).update(watcherContent)
if (platform.startsWith('win32-')) {
// Why: SSH relays install node-pty with npm on the remote, outside pnpm's
// patchedDependencies. Hashing the patch forces safe upgrade redeployment.
@@ -122,6 +122,46 @@ describe('Electron runtime package contract', () => {
)
})
it('packages and release-gates the SSH relay watcher child', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
const builderConfig = readFileSync(
join(projectDir, 'config/electron-builder.config.cjs'),
'utf8'
)
const remoteCommands = readFileSync(
join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'),
'utf8'
)
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation')
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)')
assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)')
const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name)
expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan(
releaseNames.indexOf('Build Windows release artifacts')
)
})
it('pins the Windows release builder to the VS 2022 runner image', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
@@ -0,0 +1,286 @@
import { build } from 'esbuild'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
const WAIT_TIMEOUT_MS = 20_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label, stderr) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`))
}, WAIT_TIMEOUT_MS)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function pollUntil(readValue, label, stderr) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
return new Promise((resolveValue, rejectValue) => {
const poll = async () => {
try {
const value = await readValue()
if (value !== undefined) {
resolveValue(value)
return
}
} catch {}
if (Date.now() >= deadline) {
rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`))
return
}
setTimeout(poll, 10)
}
void poll()
})
}
function waitForExit(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => proc.once('exit', resolveExit))
}
async function loadProtocol(bundleDir) {
const outfile = join(bundleDir, 'relay-protocol.cjs')
await build({
entryPoints: [resolve('src/relay/protocol.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'silent'
})
return require(outfile)
}
function createRelayClient(entryPath, args, env, protocol) {
const proc = spawn(process.execPath, [entryPath, ...args], {
cwd: dirname(entryPath),
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const messages = []
let stderr = ''
let nextSequence = 1
let stdoutBuffer = Buffer.alloc(0)
let ready = false
let resolveReady
const sentinelReceived = new Promise((resolvePromise) => {
resolveReady = resolvePromise
})
const decoder = new protocol.FrameDecoder((frame) => {
if (frame.type === protocol.MessageType.Regular) {
messages.push(protocol.parseJsonRpcMessage(frame.payload))
}
})
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
proc.stdout.on('data', (chunk) => {
if (ready) {
decoder.feed(chunk)
return
}
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
return
}
ready = true
resolveReady()
const remainder = stdoutBuffer.subarray(index + sentinel.length)
if (remainder.length > 0) {
decoder.feed(remainder)
}
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
() => stderr
)
const request = async (method, params = {}) => {
const id = nextSequence++
const startIndex = messages.length
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
const response = await waitForMessage(
startIndex,
(message) => message.id === id,
`response to ${method}`
)
if (response.error) {
throw new Error(`${method} failed: ${response.error.message}`)
}
return response.result
}
const notify = (method, params = {}) => {
const sequence = nextSequence++
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0))
}
return {
proc,
request,
notify,
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
messageCount: () => messages.length,
waitForNotification: (startIndex, method, predicate = () => true) =>
waitForMessage(
startIndex,
(message) => message.method === method && predicate(message.params ?? {}),
`${method} notification`
),
stderr: () => stderr
}
}
async function waitForWatcherPid(pidFile, previousPid, stderr) {
return pollUntil(
async () => {
const pid = Number((await readFile(pidFile, 'utf8')).trim())
if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) {
return undefined
}
// Why: replacement children reuse this exclusive path after fault injection.
await rm(pidFile, { force: true })
return pid
},
previousPid ? 'replacement watcher child pid' : 'initial watcher child pid',
stderr
)
}
function includesWatchPath(params, targetPath) {
return Array.isArray(params.events)
? params.events.some((event) => event.absolutePath === targetPath)
: false
}
async function main() {
const platform = `${process.platform}-${process.arch}`
const relayEntry = resolve('out', 'relay', platform, 'relay.js')
const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js')
if (!existsSync(relayEntry) || !existsSync(watcherEntry)) {
throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`)
}
let tempRoot
let relay
try {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
const watchRoot = await realpath(tempRoot)
const pidFile = join(tempRoot, 'watcher.pid')
const protocol = await loadProtocol(tempRoot)
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
: join(tempRoot, 'relay.sock')
relay = createRelayClient(
relayEntry,
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
protocol
)
await relay.sentinelReceived
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
let startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker)
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
await writeFile(beforePath, 'before')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, beforePath)
)
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
? params.events.some(
(event) => event.kind === 'overflow' && event.absolutePath === watchRoot
)
: false
)
const status = await relay.request('relay.status')
if (status.pid !== relay.proc.pid) {
throw new Error('relay.status did not come from the original surviving relay process')
}
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker)
)
const afterPath = join(watchRoot, 'after.txt')
startIndex = relay.messageCount()
await writeFile(afterPath, 'after')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, afterPath)
)
relay.notify('fs.unwatch', { rootPath: watchRoot })
await relay.request('pty.shutdown', { id: spawned.id })
console.log(
JSON.stringify({
relayPid: relay.proc.pid,
killedWatcherPid: firstWatcherPid,
replacementWatcherPid,
faultSignal,
relaySurvived: true,
existingPtySurvived: true,
overflowRefreshDelivered: true,
postCrashEventDelivered: true
})
)
} finally {
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
relay.proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
} catch {
relay.proc.kill('SIGKILL')
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
}
}
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
}
}
}
await main()
+136 -7
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
@@ -18,6 +19,57 @@ class FakeChildProcess extends EventEmitter {
unref = vi.fn()
}
const RECIPE_JSON = JSON.stringify({
schemaVersion: 1,
pairingCode: encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'wss://sandbox.example.com',
deviceToken: 'token',
publicKeyB64: 'public-key'
}),
projectRoot: '/workspace/repo'
})
const SERVE_INSTALL_STATUS = '[serve] orca CLI install: installed'
const SSH_PRIVATE_KEY = 'TOP-SECRET-PRIVATE-KEY'
const SSH_AUTHORIZATION = 'Bearer TOP-SECRET-AUTHORIZATION'
const SSH_PASSPHRASE = 'TOP-SECRET-PASSPHRASE'
const SSH_COOKIE = 'session=TOP-SECRET-COOKIE'
const SSH_RECIPE_JSON = JSON.stringify({
schemaVersion: 1,
connection: {
type: 'ssh',
target: {
label: 'Sandbox',
host: 'sandbox.example.com',
port: 22,
username: 'root'
},
projectRoot: '/workspace/repo'
},
userData: {
credentials: {
privateKey: SSH_PRIVATE_KEY,
authorization: SSH_AUTHORIZATION,
passphrase: SSH_PASSPHRASE,
cookie: SSH_COOKIE
}
}
})
const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo')
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
function startRecipeJsonServer() {
const child = new FakeChildProcess()
spawnMock.mockReturnValue(child)
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const result = serveOrcaApp({
recipeJson: true,
projectRoot: '/workspace/repo'
})
return { child, result, stdoutSpy, stderrSpy }
}
describe('serveOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
@@ -133,10 +185,7 @@ describe('serveOrcaApp', () => {
projectRoot: '/workspace/repo'
})
queueMicrotask(() => {
child.stdout.emit(
'data',
'{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n'
)
child.stdout.emit('data', `${RECIPE_JSON}\n`)
})
await expect(result).resolves.toBe(0)
@@ -157,12 +206,92 @@ describe('serveOrcaApp', () => {
stdio: ['ignore', 'pipe', 'inherit']
})
)
expect(writeSpy).toHaveBeenCalledWith(
'{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n'
)
expect(writeSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalled()
})
it('waits past startup status lines for valid recipe JSON', async () => {
const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer()
queueMicrotask(() => {
child.stdout.emit(
'data',
`${SERVE_INSTALL_STATUS}\n${INVALID_SSH_RECIPE_JSON}\n${SSH_RECIPE_JSON}\n${RECIPE_JSON.slice(0, 40)}`
)
child.stdout.emit('data', `${RECIPE_JSON.slice(40)}\n`)
})
await expect(result).resolves.toBe(0)
expect(stderrSpy).toHaveBeenNthCalledWith(1, `${IGNORED_NON_RECIPE_STDOUT}\n`)
expect(stderrSpy).toHaveBeenNthCalledWith(2, `${IGNORED_NON_RECIPE_STDOUT}\n`)
expect(stderrSpy).toHaveBeenNthCalledWith(3, `${IGNORED_NON_RECIPE_STDOUT}\n`)
for (const secret of [SSH_PRIVATE_KEY, SSH_AUTHORIZATION, SSH_PASSPHRASE, SSH_COOKIE]) {
expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret))
}
expect(stdoutSpy).toHaveBeenCalledTimes(1)
expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalledOnce()
})
it('preserves UTF-8 recipe JSON split across Buffer chunks', async () => {
const { child, result, stdoutSpy } = startRecipeJsonServer()
const unicodeRecipeJson = RECIPE_JSON.replace('/workspace/repo', '/workspace/café')
const recipeBuffer = Buffer.from(`${unicodeRecipeJson}\n`)
const splitIndex = recipeBuffer.indexOf(Buffer.from('é')) + 1
queueMicrotask(() => {
child.stdout.emit('data', recipeBuffer.subarray(0, splitIndex))
child.stdout.emit('data', recipeBuffer.subarray(splitIndex))
})
await expect(result).resolves.toBe(0)
expect(stdoutSpy).toHaveBeenCalledWith(`${unicodeRecipeJson}\n`)
expect(child.unref).toHaveBeenCalledOnce()
})
it('rejects when the server exits without valid recipe JSON', async () => {
const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer()
const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET']
const untrustedLines = [
'ORCA://pair?code=UPPER-SECRET',
'orca://pair/?code=SLASH-SECRET',
'orca://pair#LEGACY-SECRET',
'"embedded privateKey PRIVATE-SECRET"',
'{privateKey:"PRIVATE-SECRET"}'
].join('\n')
queueMicrotask(() => {
child.stdout.emit('data', `${untrustedLines}\n`)
child.emit('exit', 0, null)
child.emit('close', 0, null)
})
await expect(result).rejects.toMatchObject({
code: 'runtime_serve_failed',
message: 'Orca serve exited before printing valid recipe JSON with code 0.'
})
expect(stdoutSpy).not.toHaveBeenCalled()
expect(stderrSpy).toHaveBeenCalledTimes(5)
expect(stderrSpy).toHaveBeenCalledWith(`${IGNORED_NON_RECIPE_STDOUT}\n`)
for (const secret of secrets) {
expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret))
}
expect(child.unref).not.toHaveBeenCalled()
})
it('accepts valid recipe JSON at exit without a trailing newline', async () => {
const { child, result, stdoutSpy } = startRecipeJsonServer()
queueMicrotask(() => {
child.emit('exit', 0, null)
child.stdout.emit('data', RECIPE_JSON)
child.emit('close', 0, null)
})
await expect(result).resolves.toBe(0)
expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalledOnce()
})
it('uses a shell when a Windows npm command shim is the Electron executable', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
+50 -16
View File
@@ -1,7 +1,14 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { RuntimeClientError } from './types'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
@@ -155,7 +162,7 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('exit', onExit)
child.off('close', onClose)
if (error) {
reject(error)
return
@@ -164,42 +171,69 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
child.unref()
resolve(0)
}
const emitLine = (line: string): void => {
process.stdout.write(`${line}\n`)
finish()
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const onData = (chunk: Buffer | string): void => {
output += chunk.toString()
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
emitLine(output.slice(0, newlineIndex))
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
const trimmed = output.trim()
if (trimmed) {
emitLine(trimmed)
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing recipe JSON with code ${code}.`
: `Orca serve exited before printing recipe JSON via ${signal}.`
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
child.once('exit', onExit)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}
@@ -1,4 +1,5 @@
import { fork, type ChildProcess } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import {
createWatcherCanaryDirectory,
removeWatcherCanaryDirectory
@@ -33,6 +34,16 @@ export function launchWatcherChild(
console.error('[parcel-watcher-process] failed to fork watcher process:', error)
return null
}
const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE
if (faultHarnessPidFile && child.pid) {
try {
// Why: exclusive creation lets the harness identify the child without a
// leaked test-only environment variable clobbering an existing file.
writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' })
} catch {
// Fault-injection observability must never affect watcher availability.
}
}
child.stderr?.on('data', (chunk: Buffer) => {
console.error('[parcel-watcher-process]', String(chunk).trimEnd())
})
@@ -100,6 +100,21 @@ export function resolvePendingWatcherUnsubscribes(
pendingUnsubscribes.clear()
}
export function disposeWatcherSupervisorSubscriptions(
records: Map<number, WatcherProcessSubscriptionRecord>,
pendingUnsubscribes: Map<number, () => void>,
cancelledSubscribesAwaitingChild: Set<number>,
error: Error
): void {
for (const record of records.values()) {
resetPendingSubscribeAttempt(record)
takePendingSubscribe(record)?.reject(error)
}
resolvePendingWatcherUnsubscribes(pendingUnsubscribes)
cancelledSubscribesAwaitingChild.clear()
records.clear()
}
export type CreateHostWatcherSubscriptionOptions = {
record: WatcherProcessSubscriptionRecord
records: Map<number, WatcherProcessSubscriptionRecord>
@@ -20,6 +20,13 @@ export class WatcherProcessFailure extends Error {
}
}
export function watcherHostFailure(
message: string,
code: WatcherProcessFailureCode
): WatcherProcessFailure {
return new WatcherProcessFailure(message, 'supervisor', code)
}
export function isWatcherProcessFailure(error: unknown): error is WatcherProcessFailure {
return error instanceof WatcherProcessFailure
}
@@ -0,0 +1,4 @@
export type WatcherProcessSupervisorOptions = {
entryPath?: string
useInProcessVitestFallback?: boolean
}
@@ -5,6 +5,7 @@ import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory'
import { launchWatcherChild } from './parcel-watcher-child-launch'
import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse'
import {
disposeWatcherSupervisorSubscriptions,
failAllWatcherSubscriptions,
handleWatcherHostMessage,
reportWatcherTerminalError,
@@ -16,7 +17,8 @@ import {
startPendingSubscribeTimeout,
takePendingSubscribe
} from './parcel-watcher-pending-subscribe'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import { watcherHostFailure } from './parcel-watcher-process-failure'
import type { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
HostToWatcherMessage,
WatcherProcessSubscribeOptions,
@@ -28,6 +30,7 @@ import type {
WatcherProcessSubscription,
WatcherProcessSubscriptionRecord
} from './parcel-watcher-process-subscription'
import type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
import { subscribeThroughWatcherSupervisor } from './parcel-watcher-supervisor-subscribe'
export type {
@@ -35,6 +38,7 @@ export type {
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
export type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
export class WatcherProcessSupervisor {
private child: ChildProcess | null = null
@@ -45,6 +49,9 @@ export class WatcherProcessSupervisor {
private readonly records = new Map<number, WatcherProcessSubscriptionRecord>()
private readonly pendingUnsubscribes = new Map<number, () => void>()
private readonly cancelledSubscribesAwaitingChild = new Set<number>()
constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {}
subscribe(
dir: string,
callback: WatcherProcessCallback,
@@ -57,6 +64,8 @@ export class WatcherProcessSupervisor {
opts,
hooks,
shutdownRequested: this.shutdownRequested,
entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(),
useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true,
allocateId: () => this.nextSubscriptionId++,
records: this.records,
pendingUnsubscribes: this.pendingUnsubscribes,
@@ -73,19 +82,13 @@ export class WatcherProcessSupervisor {
this.shutdownRequested = true
const proc = this.child
this.child = null
const error = new WatcherProcessFailure(
'file watcher supervisor disposed',
'supervisor',
'supervisor_disposed'
const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed')
disposeWatcherSupervisorSubscriptions(
this.records,
this.pendingUnsubscribes,
this.cancelledSubscribesAwaitingChild,
error
)
for (const record of this.records.values()) {
resetPendingSubscribeAttempt(record)
const pending = takePendingSubscribe(record)
pending?.reject(error)
}
resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
this.cancelledSubscribesAwaitingChild.clear()
this.records.clear()
proc?.kill()
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
}
@@ -96,7 +99,9 @@ export class WatcherProcessSupervisor {
this.crashFuse.reset()
}
private ensureWatcherProcess(entryPath = getWatcherProcessEntryPath()): ChildProcess | null {
private ensureWatcherProcess(
entryPath = this.options.entryPath ?? getWatcherProcessEntryPath()
): ChildProcess | null {
if (this.shutdownRequested) {
return null
}
@@ -200,11 +205,7 @@ export class WatcherProcessSupervisor {
)
failAllWatcherSubscriptions(
this.records,
new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
watcherHostFailure('file watcher process crashed repeatedly', 'supervisor_crash_fuse')
)
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return
@@ -279,9 +280,8 @@ export class WatcherProcessSupervisor {
if (!replacement) {
failAllWatcherSubscriptions(
this.records,
new WatcherProcessFailure(
watcherHostFailure(
'file watcher process unavailable after subscription cancellation',
'supervisor',
'process_unavailable'
)
)
+31 -10
View File
@@ -4,21 +4,28 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { forkMock, existsSyncMock, mkdtempSyncMock, parcelSubscribeMock, rmSyncMock } = vi.hoisted(
() => ({
forkMock: vi.fn(),
existsSyncMock: vi.fn(),
mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'),
parcelSubscribeMock: vi.fn(),
rmSyncMock: vi.fn()
})
)
const {
forkMock,
existsSyncMock,
mkdtempSyncMock,
parcelSubscribeMock,
rmSyncMock,
writeFileSyncMock
} = vi.hoisted(() => ({
forkMock: vi.fn(),
existsSyncMock: vi.fn(),
mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'),
parcelSubscribeMock: vi.fn(),
rmSyncMock: vi.fn(),
writeFileSyncMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ fork: forkMock }))
vi.mock('node:fs', () => ({
existsSync: existsSyncMock,
mkdtempSync: mkdtempSyncMock,
rmSync: rmSyncMock
rmSync: rmSyncMock,
writeFileSync: writeFileSyncMock
}))
vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock }))
@@ -35,6 +42,7 @@ type SentMessage = { op: string; id: number; dir?: string }
class FakeChild extends EventEmitter {
connected = true
pid = 1234
sent: SentMessage[] = []
stderr = new EventEmitter()
kill = vi.fn(() => {
@@ -94,6 +102,19 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
it('creates the fault-harness pid file without clobbering an existing path', async () => {
vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid')
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const child = currentChild()
expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', {
flag: 'wx'
})
ackSubscribe(child)
await promise
})
it('forwards watcher errors to the callback', async () => {
const callback = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {})
@@ -1,6 +1,5 @@
import type { ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions'
import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback'
import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe'
@@ -22,6 +21,8 @@ type WatcherSupervisorSubscribeOptions = {
opts: WatcherProcessSubscribeOptions
hooks: WatcherProcessHooks
shutdownRequested: boolean
entryPath: string
useInProcessVitestFallback: boolean
allocateId: () => number
records: Map<number, WatcherProcessSubscriptionRecord>
pendingUnsubscribes: Map<number, () => void>
@@ -42,6 +43,8 @@ export function subscribeThroughWatcherSupervisor({
opts,
hooks,
shutdownRequested,
entryPath,
useInProcessVitestFallback,
allocateId,
records,
pendingUnsubscribes,
@@ -72,10 +75,9 @@ export function subscribeThroughWatcherSupervisor({
}
// Why: under Vitest we cannot fork a real watcher child, so exercise the
// subscription path in-process (against mocked @parcel/watcher) instead.
if (process.env.VITEST) {
if (process.env.VITEST && useInProcessVitestFallback) {
return subscribeWithInProcessWatcher(dir, callback, opts, hooks)
}
const entryPath = getWatcherProcessEntryPath()
if (!existsSync(entryPath)) {
return Promise.reject(
new WatcherProcessFailure(
+108
View File
@@ -45,4 +45,112 @@ describe('readGrokAuthSession', () => {
error: 'Grok auth file is invalid'
})
})
it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])(
'prefers the %s issuer entry over an earlier alternate issuer',
async (preferredIssuer) => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() =>
JSON.stringify({
'https://stale.example.com::client': {
key: 'stale-token',
user_id: 'stale-user',
email: 'stale@example.com',
expires_at: '2099-01-01T00:00:00.000Z'
},
[preferredIssuer]: {
key: 'live-token',
user_id: 'live-user',
email: 'live@example.com',
team_id: 'team-1',
expires_at: '2099-06-01T00:00:00.000Z',
oidc_client_id: 'client-1'
}
})
)
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toEqual({
status: 'ok',
session: {
accessToken: 'live-token',
userId: 'live-user',
email: 'live@example.com',
teamId: 'team-1',
expiresAtMs: Date.parse('2099-06-01T00:00:00.000Z'),
oidcClientId: 'client-1'
}
})
}
)
it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() =>
JSON.stringify({
'https://alternate.example.com::client': {
key: 'alt-token',
user_id: 'alt-user',
email: 'alt@example.com',
expires_at: '2099-01-01T00:00:00.000Z'
}
})
)
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toEqual({
status: 'ok',
session: {
accessToken: 'alt-token',
userId: 'alt-user',
email: 'alt@example.com',
teamId: null,
expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'),
oidcClientId: null
}
})
})
it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() =>
JSON.stringify({
'https://auth.x.ai::old-client': {
key: 'expired-token',
expires_at: '2020-01-01T00:00:00.000Z'
},
'https://auth.x.ai::current-client': {
key: 'fresh-token',
expires_at: '2099-01-01T00:00:00.000Z'
}
})
)
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toMatchObject({
status: 'ok',
session: { accessToken: 'fresh-token' }
})
})
it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() =>
JSON.stringify({
'https://alternate.example.com::client': { key: 'stale-token' },
'https://auth.x.ai::client': { user_id: 'signed-out-user' }
})
)
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toEqual({ status: 'missing' })
})
})
+43 -14
View File
@@ -29,6 +29,8 @@ type GrokAuthEntry = {
oidc_client_id?: string
}
type TokenizedGrokAuthEntry = GrokAuthEntry & { key: string }
export type GrokAuthReadResult =
| { status: 'missing' }
| { status: 'error'; error: string }
@@ -43,7 +45,7 @@ function getGrokAuthReadError(err: unknown): string {
return 'Unable to read Grok auth file'
}
function parseAuthEntry(value: unknown): GrokAuthEntry | null {
function parseAuthEntry(value: unknown): TokenizedGrokAuthEntry | null {
if (typeof value !== 'object' || value === null) {
return null
}
@@ -51,7 +53,7 @@ function parseAuthEntry(value: unknown): GrokAuthEntry | null {
if (typeof entry.key !== 'string' || entry.key.length === 0) {
return null
}
return entry
return entry as TokenizedGrokAuthEntry
}
function parseExpiresAtMs(iso: string | undefined): number | null {
@@ -62,6 +64,24 @@ function parseExpiresAtMs(iso: string | undefined): number | null {
return Number.isFinite(ms) ? ms : null
}
// Why: stale alternate issuers can precede the default xAI OAuth session in auth.json.
const PREFERRED_GROK_AUTH_ISSUER = 'https://auth.x.ai'
function sessionFromAuthEntry(authEntry: TokenizedGrokAuthEntry): GrokAuthSession {
return {
accessToken: authEntry.key,
userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null,
email: typeof authEntry.email === 'string' ? authEntry.email : null,
teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null,
expiresAtMs: parseExpiresAtMs(authEntry.expires_at),
oidcClientId: typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null
}
}
function isPreferredGrokAuthKey(key: string): boolean {
return key === PREFERRED_GROK_AUTH_ISSUER || key.startsWith(`${PREFERRED_GROK_AUTH_ISSUER}::`)
}
export function readGrokAuthSession(): GrokAuthReadResult {
const path = getGrokAuthPath()
if (!existsSync(path)) {
@@ -72,23 +92,32 @@ export function readGrokAuthSession(): GrokAuthReadResult {
if (typeof parsed !== 'object' || parsed === null) {
return { status: 'error', error: 'Grok auth file is invalid' }
}
for (const entry of Object.values(parsed)) {
let preferredKeySeen = false
let expiredPreferred: GrokAuthSession | null = null
let fallback: GrokAuthSession | null = null
for (const [key, entry] of Object.entries(parsed as Record<string, unknown>)) {
const isPreferred = isPreferredGrokAuthKey(key)
preferredKeySeen ||= isPreferred
const authEntry = parseAuthEntry(entry)
if (!authEntry?.key) {
if (!authEntry) {
continue
}
return {
status: 'ok',
session: {
accessToken: authEntry.key,
userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null,
email: typeof authEntry.email === 'string' ? authEntry.email : null,
teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null,
expiresAtMs: parseExpiresAtMs(authEntry.expires_at),
oidcClientId:
typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null
const session = sessionFromAuthEntry(authEntry)
if (isPreferred) {
if (isGrokAccessTokenFresh(session)) {
return { status: 'ok', session }
}
expiredPreferred ??= session
continue
}
if (!fallback) {
fallback = session
}
}
// Why: alternate issuers are compatibility fallbacks only when no default entry exists.
const selectedSession = expiredPreferred ?? (preferredKeySeen ? null : fallback)
if (selectedSession) {
return { status: 'ok', session: selectedSession }
}
// Why: a token-less file (e.g. after grok logout) means signed out, not a
// failure — 'error' would keep a status-bar alert visible for that user.
@@ -108,11 +108,12 @@ describe('isRelayAlreadyInstalled', () => {
).rejects.toBe(sessionLimitError)
})
it('checks for relay.js AND .install-complete in addition to the dir', async () => {
it('checks for both relay process artifacts and .install-complete', async () => {
mockExec.mockResolvedValueOnce('OK')
await isRelayAlreadyInstalled(conn, '/r')
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(cmd).toContain('relay.js')
expect(cmd).toContain('relay-watcher.js')
expect(cmd).toContain('.install-complete')
})
})
+3 -4
View File
@@ -126,10 +126,9 @@ export function computeRemoteRelayDir(
/**
* Probe whether a fully-installed relay already exists at remoteRelayDir.
*
* "Fully installed" means: the directory exists, contains relay.js, AND
* contains the .install-complete sentinel written at the end of a successful
* install. A directory missing .install-complete is either mid-install (lock
* held) or a crashed-install partial — either way we re-run the deploy.
* "Fully installed" means: the directory contains relay.js, its isolated
* relay-watcher.js child, and the .install-complete sentinel written at the
* end of a successful install. Missing artifacts force a complete re-deploy.
*/
export async function isRelayAlreadyInstalled(
conn: SshConnection,
+4 -1
View File
@@ -59,11 +59,13 @@ export function probeRelayInstalledCommand(
remoteRelayDir: string
): string {
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
if (!isWindowsRemoteHost(host)) {
return (
`test -d ${shellEscape(remoteRelayDir)} ` +
`&& test -f ${shellEscape(relayJs)} ` +
`&& test -f ${shellEscape(relayWatcherJs)} ` +
`&& test -f ${shellEscape(installComplete)} ` +
`&& echo OK || echo MISSING`
)
@@ -72,8 +74,9 @@ export function probeRelayInstalledCommand(
[
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
`$relay = ${powerShellLiteral(relayJs)}`,
`$watcher = ${powerShellLiteral(relayWatcherJs)}`,
`$complete = ${powerShellLiteral(installComplete)}`,
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
].join('; ')
)
}
+6 -1
View File
@@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback'
const { mockSubscribe } = vi.hoisted(() => ({
mockSubscribe: vi.fn()
@@ -121,7 +122,11 @@ describe('FsHandler', () => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-'))
dispatcher = createMockDispatcher()
const ctx = new RelayContext()
handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx)
handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx, {
dispose: vi.fn(),
forgetRoot: vi.fn(),
subscribe: subscribeWithInProcessWatcher
})
})
afterEach(async () => {
+18 -136
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: relay filesystem request handling shares
path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */
path expansion, file IO, search, streaming reads, and Space scans. */
import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
@@ -34,17 +34,8 @@ import { RelayStreamRegistry } from './fs-stream-registry'
import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
import { buildRelayCommandEnv } from './relay-command-env'
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
import {
WATCHER_IGNORE_DIRS,
buildParcelWatcherIgnoreOptions
} from '../main/ipc/filesystem-watcher-ignore'
type WatchState = {
rootPath: string
unwatchFn: (() => void) | null
setupPromise: Promise<void> | null
clients: Map<number, () => boolean>
}
import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
import type { RelayWatcherProcessPool } from './relay-watcher-process-pool'
async function isDirectoryEntry(
dirPath: string,
@@ -85,15 +76,19 @@ function fileStatFromLstat(stats: Awaited<ReturnType<typeof lstat>>) {
export class FsHandler {
private dispatcher: RelayDispatcher
private watches = new Map<string, WatchState>()
private watchRegistry: RelayFilesystemWatchRegistry
private streamRegistry = new RelayStreamRegistry()
private listFilesScans = new ListFilesScanCoordinator()
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
constructor(
dispatcher: RelayDispatcher,
_context: RelayContext,
watcherPool?: RelayWatcherProcessPool
) {
this.dispatcher = dispatcher
this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
this.registerHandlers()
this.dispatcher.onClientDetached?.((clientId) => {
this.releaseClientWatches(clientId)
this.dispatcher.onClientDetached?.(() => {
// Why: a detached client's fs.streamAck frames will never arrive; wake
// any pump parked on the ack window so it re-checks staleness and exits
// instead of stranding its open file handle.
@@ -122,8 +117,12 @@ export class FsHandler {
this.dispatcher.onRequest('fs.search', (p) => this.search(p))
this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c))
this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c))
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
this.dispatcher.onRequest('fs.watch', (p, context) =>
this.watchRegistry.watch(expandTilde(p.rootPath as string), context)
)
this.dispatcher.onNotification('fs.unwatch', (p, context) =>
this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context)
)
this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
}
@@ -414,125 +413,8 @@ export class FsHandler {
return scanWorkspaceSpaceDirectory(rootPath, context)
}
private async watch(params: Record<string, unknown>, context?: RequestContext) {
const rootPath = expandTilde(params.rootPath as string)
this.releaseStaleWatches()
const existing = this.watches.get(rootPath)
if (existing) {
if ([...existing.clients.values()].some((isStale) => !isStale())) {
existing.clients.set(context?.clientId ?? 0, context?.isStale ?? (() => false))
if (existing.setupPromise) {
await existing.setupPromise
}
return
}
existing.unwatchFn?.()
this.watches.delete(rootPath)
}
if (this.watches.size >= 20) {
throw new Error('Maximum number of file watchers reached')
}
const watchState: WatchState = {
rootPath,
unwatchFn: null,
setupPromise: null,
clients: new Map([[context?.clientId ?? 0, context?.isStale ?? (() => false)]])
}
this.watches.set(rootPath, watchState)
const setupPromise = (async () => {
const watcher = await import('@parcel/watcher')
const subscription = await watcher.subscribe(
rootPath,
(err, events) => {
if (err) {
this.dispatcher.notify('fs.changed', {
events: [{ kind: 'overflow', absolutePath: rootPath }]
})
return
}
const mapped = events.map((evt) => ({
kind: evt.type,
absolutePath: evt.path
}))
this.dispatcher.notify('fs.changed', { events: mapped })
},
// Why: align remote watchers with the shared nested exclusion so
// generated trees neither exhaust inotify nor trigger slow glob regexes.
buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
)
watchState.unwatchFn = () => {
void subscription.unsubscribe()
}
if (
[...watchState.clients.values()].every((isStale) => isStale()) ||
this.watches.get(rootPath) !== watchState
) {
// Why: if the only requesting client reconnects while watcher setup is
// in flight, no client can later balance it with fs.unwatch. Tear down
// only this request's subscription so a newer replacement watch for the
// same root is not removed.
void subscription.unsubscribe()
if (this.watches.get(rootPath) === watchState) {
this.watches.delete(rootPath)
}
}
})()
watchState.setupPromise = setupPromise
try {
await setupPromise
} catch {
if (this.watches.get(rootPath) === watchState) {
this.watches.delete(rootPath)
}
// @parcel/watcher not available -- polling fallback would go here
process.stderr.write('[relay] File watcher not available, fs.changed events disabled\n')
}
}
private unwatch(params: Record<string, unknown>, context?: RequestContext): void {
const rootPath = expandTilde(params.rootPath as string)
const state = this.watches.get(rootPath)
if (state) {
this.releaseWatchClient(rootPath, state, context?.clientId ?? 0)
}
}
private releaseClientWatches(clientId: number): void {
for (const [rootPath, state] of this.watches) {
this.releaseWatchClient(rootPath, state, clientId)
}
}
private releaseStaleWatches(): void {
for (const [rootPath, state] of this.watches) {
if ([...state.clients.values()].some((isStale) => !isStale())) {
continue
}
state.unwatchFn?.()
this.watches.delete(rootPath)
}
}
private releaseWatchClient(rootPath: string, state: WatchState, clientId: number): void {
state.clients.delete(clientId)
if (state.clients.size > 0) {
return
}
state.unwatchFn?.()
this.watches.delete(rootPath)
}
dispose(): void {
for (const [, state] of this.watches) {
state.unwatchFn?.()
}
this.watches.clear()
this.watchRegistry.dispose()
void this.streamRegistry.disposeAll()
}
}
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from '../main/ipc/parcel-watcher-process-subscription'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
import { createRelayWatcherProcessPool } from './relay-watcher-process-pool'
type InstalledWatch = {
callback: WatcherProcessCallback
hooks: WatcherProcessHooks
unsubscribe: ReturnType<typeof vi.fn<() => Promise<void>>>
}
class FakeWatcherPool {
readonly installed: InstalledWatch[] = []
readonly dispose = vi.fn()
readonly forgetRoot = vi.fn()
async subscribe(
_rootPath: string,
callback: WatcherProcessCallback,
_options: object,
hooks: WatcherProcessHooks
): Promise<WatcherProcessSubscription> {
const unsubscribe = vi.fn(async () => undefined)
this.installed.push({ callback, hooks, unsubscribe })
return { unsubscribe }
}
}
function createDispatcher() {
const detached = new Set<(clientId: number) => void>()
return {
notify: vi.fn(),
onClientDetached: vi.fn((listener: (clientId: number) => void) => {
detached.add(listener)
return () => detached.delete(listener)
})
}
}
function context(clientId: number): RequestContext {
return { clientId, isStale: () => false }
}
describe('RelayFilesystemWatchRegistry', () => {
let dispatcher: ReturnType<typeof createDispatcher>
let pool: FakeWatcherPool
let registry: RelayFilesystemWatchRegistry
beforeEach(() => {
dispatcher = createDispatcher()
pool = new FakeWatcherPool()
registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool)
})
it('emits overflow around child replacement and resumes ordered event delivery', async () => {
await registry.watch('/repo', context(1))
const first = pool.installed[0]
first.callback(null, [{ type: 'create', path: '/repo/before.txt' }])
first.hooks.onInterruption?.()
first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }])
expect(dispatcher.notify.mock.calls).toEqual([
['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }],
['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }],
['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }]
])
})
it('moves a terminal shard failure into recovery without dropping shared clients', async () => {
await registry.watch('/repo', context(1))
await registry.watch('/repo', context(2))
const first = pool.installed[0]
first.hooks.onTerminalError?.(
new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
)
await Promise.resolve()
expect(pool.installed).toHaveLength(2)
pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }])
expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', {
events: [{ kind: 'overflow', absolutePath: '/repo' }]
})
expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', {
events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }]
})
registry.unwatch('/repo', context(1))
expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled()
registry.unwatch('/repo', context(2))
expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1)
})
it('aborts a pending crawl only after the last same-root client leaves', async () => {
let sharedSignal: AbortSignal | undefined
let rejectSubscribe: ((error: Error) => void) | undefined
vi.spyOn(pool, 'subscribe').mockImplementation(
(_rootPath, _callback, _options, hooks): Promise<WatcherProcessSubscription> =>
new Promise<WatcherProcessSubscription>((_resolve, reject) => {
sharedSignal = hooks.signal
rejectSubscribe = reject
hooks.signal?.addEventListener(
'abort',
() =>
reject(
new WatcherProcessFailure(
'file watcher subscription aborted',
'subscription',
'subscribe_aborted'
)
),
{ once: true }
)
})
)
const firstAbort = new AbortController()
const secondAbort = new AbortController()
const first = registry.watch('/repo', {
...context(1),
signal: firstAbort.signal
})
const second = registry.watch('/repo', {
...context(2),
signal: secondAbort.signal
})
firstAbort.abort()
await first
expect(sharedSignal?.aborted).toBe(false)
secondAbort.abort()
await second
expect(sharedSignal?.aborted).toBe(true)
expect(rejectSubscribe).toBeDefined()
})
})
describe('createRelayWatcherProcessPool', () => {
it('fails closed instead of loading the native watcher in the relay process', async () => {
const previousVitest = process.env.VITEST
process.env.VITEST = 'true'
const pool = createRelayWatcherProcessPool(
join(tmpdir(), `missing-relay-watcher-${process.pid}.js`)
)
try {
await expect(pool.subscribe('/repo', vi.fn(), {}, {})).rejects.toMatchObject({
code: 'entry_missing'
})
} finally {
pool.dispose()
if (previousVitest === undefined) {
delete process.env.VITEST
} else {
process.env.VITEST = previousVitest
}
}
})
})
@@ -0,0 +1,312 @@
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch'
import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure'
import type {
WatcherProcessEvent,
WatcherProcessSubscription
} from '../main/ipc/parcel-watcher-process'
import {
WATCHER_IGNORE_DIRS,
buildParcelWatcherIgnoreOptions
} from '../main/ipc/filesystem-watcher-ignore'
import {
createRelayWatcherProcessPool,
type RelayWatcherProcessPool
} from './relay-watcher-process-pool'
const MAX_RELAY_WATCH_ROOTS = 20
const RELAY_WATCH_CRAWL_TIMEOUT_MS = 60_000
const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
type RelayWatchState = {
rootPath: string
clients: Map<number, () => boolean>
setupPromise: Promise<void>
subscription: WatcherProcessSubscription | null
abortController: AbortController
generation: number
closed: boolean
}
function overflowEvent(rootPath: string): Record<string, unknown> {
return { events: [{ kind: 'overflow', absolutePath: rootPath }] }
}
function createWatchAbortError(): Error {
const error = new Error('Request "fs.watch" was cancelled')
error.name = 'AbortError'
return error
}
function shouldRetryInitialWatch(error: unknown): boolean {
return (
isWatcherProcessFailure(error) &&
error.code !== 'entry_missing' &&
error.code !== 'subscribe_aborted' &&
error.code !== 'supervisor_disposed' &&
(error.scope === 'supervisor' || error.code === 'subscribe_timeout')
)
}
export class RelayFilesystemWatchRegistry {
private readonly watches = new Map<string, RelayWatchState>()
constructor(
private readonly dispatcher: RelayDispatcher,
private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool()
) {
this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
}
async watch(rootPath: string, context?: RequestContext): Promise<void> {
this.releaseStaleWatches()
const clientId = context?.clientId ?? 0
const isStale = context?.isStale ?? (() => false)
const existing = this.watches.get(rootPath)
if (existing) {
existing.clients.set(clientId, isStale)
await this.awaitSetupForClient(existing, clientId, context)
return
}
if (this.watches.size >= MAX_RELAY_WATCH_ROOTS) {
throw new Error('Maximum number of file watchers reached')
}
const state: RelayWatchState = {
rootPath,
clients: new Map([[clientId, isStale]]),
setupPromise: Promise.resolve(),
subscription: null,
abortController: new AbortController(),
generation: 0,
closed: false
}
this.watches.set(rootPath, state)
state.setupPromise = this.startInitialWatch(state)
await this.awaitSetupForClient(state, clientId, context)
}
unwatch(rootPath: string, context?: RequestContext): void {
const state = this.watches.get(rootPath)
if (state) {
this.releaseWatchClient(state, context?.clientId ?? 0)
}
}
dispose(): void {
for (const state of Array.from(this.watches.values())) {
this.closeWatch(state)
}
this.watcherPool.dispose()
}
private async startInitialWatch(state: RelayWatchState): Promise<void> {
try {
await this.subscribeState(state)
} catch (firstError) {
if (!state.closed && shouldRetryInitialWatch(firstError)) {
try {
await this.subscribeState(state)
this.emitOverflow(state)
return
} catch (quarantineError) {
this.closeWatch(state)
throw quarantineError
}
}
this.closeWatch(state)
throw firstError
}
}
private subscribeState(state: RelayWatchState): Promise<void> {
const generation = ++state.generation
const emitOverflow = (): void => {
if (state.generation === generation) {
this.emitOverflow(state)
}
}
return this.watcherPool
.subscribe(
state.rootPath,
(error, events) => {
if (state.closed || state.generation !== generation) {
return
}
if (error) {
process.stderr.write(
`[relay] File watcher error for ${state.rootPath}: ${error.message}\n`
)
emitOverflow()
return
}
this.emitEvents(state, events)
},
RELAY_WATCH_OPTIONS,
{
delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
onInterruption: emitOverflow,
onOverflow: emitOverflow,
onTerminalError: (error) => this.recoverWatch(state, generation, error),
signal: state.abortController.signal,
subscribeTimeoutMs: RELAY_WATCH_CRAWL_TIMEOUT_MS
}
)
.then(async (subscription) => {
if (
state.closed ||
state.generation !== generation ||
this.watches.get(state.rootPath) !== state
) {
await subscription.unsubscribe()
return
}
state.subscription = subscription
})
}
private recoverWatch(state: RelayWatchState, failedGeneration: number, error: Error): void {
if (state.closed || state.generation !== failedGeneration) {
return
}
state.subscription = null
this.emitOverflow(state)
const recovery = this.subscribeState(state)
state.setupPromise = recovery
void recovery.catch((recoveryError: unknown) => {
if (!state.closed) {
const message = recoveryError instanceof Error ? recoveryError.message : error.message
process.stderr.write(
`[relay] File watcher disabled after bounded recovery for ${state.rootPath}: ${message}\n`
)
this.closeWatch(state)
}
})
}
private emitEvents(state: RelayWatchState, events: readonly WatcherProcessEvent[]): void {
if (state.closed || events.length === 0) {
return
}
this.dispatcher.notify('fs.changed', {
events: events.map((event) => ({
kind: event.type,
absolutePath: event.path,
...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory })
}))
})
}
private emitOverflow(state: RelayWatchState): void {
if (!state.closed) {
this.dispatcher.notify('fs.changed', overflowEvent(state.rootPath))
}
}
private async awaitSetupForClient(
state: RelayWatchState,
clientId: number,
context?: RequestContext
): Promise<void> {
try {
await this.awaitSetupWithAbort(state.setupPromise, context?.signal)
} catch (error) {
this.releaseWatchClient(state, clientId)
const expectedAbort =
(error instanceof Error && error.name === 'AbortError') ||
(isWatcherProcessFailure(error) && error.code === 'subscribe_aborted')
if (!expectedAbort && error instanceof Error) {
process.stderr.write(
`[relay] File watcher not available for ${state.rootPath}: ${error.message}\n`
)
throw error
}
return
}
if (context?.isStale()) {
this.releaseWatchClient(state, clientId)
}
}
private awaitSetupWithAbort(setupPromise: Promise<void>, signal?: AbortSignal): Promise<void> {
if (!signal) {
return setupPromise
}
if (signal.aborted) {
return Promise.reject(createWatchAbortError())
}
return new Promise((resolve, reject) => {
const onAbort = (): void => {
cleanup()
reject(createWatchAbortError())
}
const cleanup = (): void => signal.removeEventListener('abort', onAbort)
signal.addEventListener('abort', onAbort, { once: true })
setupPromise.then(
() => {
cleanup()
resolve()
},
(error) => {
cleanup()
reject(error)
}
)
})
}
private releaseClientWatches(clientId: number): void {
for (const state of Array.from(this.watches.values())) {
this.releaseWatchClient(state, clientId)
}
}
private releaseStaleWatches(): void {
for (const state of Array.from(this.watches.values())) {
for (const [clientId, isStale] of state.clients) {
if (isStale()) {
state.clients.delete(clientId)
}
}
if (state.clients.size === 0) {
this.closeWatch(state)
}
}
}
private releaseWatchClient(state: RelayWatchState, clientId: number): void {
state.clients.delete(clientId)
if (!state.closed && state.clients.size === 0) {
this.closeWatch(state)
}
}
private closeWatch(state: RelayWatchState): void {
if (state.closed) {
return
}
state.closed = true
state.generation++
state.abortController.abort()
state.clients.clear()
if (this.watches.get(state.rootPath) === state) {
this.watches.delete(state.rootPath)
}
const subscription = state.subscription
state.subscription = null
if (subscription) {
// Why: a child can die during unwatch; release quarantine history even
// when physical teardown reports that already-contained failure.
void subscription.unsubscribe().then(
() => this.watcherPool.forgetRoot(state.rootPath),
() => this.watcherPool.forgetRoot(state.rootPath)
)
return
}
void state.setupPromise.then(
() => this.watcherPool.forgetRoot(state.rootPath),
() => this.watcherPool.forgetRoot(state.rootPath)
)
}
}
+26
View File
@@ -0,0 +1,26 @@
import { join } from 'node:path'
import { RuntimeWatcherProcessPool } from '../main/ipc/runtime-watcher-process-pool'
import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor'
export type RelayWatcherProcessPool = Pick<
RuntimeWatcherProcessPool,
'dispose' | 'forgetRoot' | 'subscribe'
>
export function getRelayWatcherProcessEntryPath(): string {
return join(__dirname, 'relay-watcher.js')
}
export function createRelayWatcherProcessPool(
entryPath = getRelayWatcherProcessEntryPath()
): RelayWatcherProcessPool {
return new RuntimeWatcherProcessPool({
createSupervisor: () =>
new WatcherProcessSupervisor({
entryPath,
// Why: a leaked VITEST environment must never move the native addon
// back into the relay when its crash-isolation child is missing.
useInProcessVitestFallback: false
})
})
}
+12 -1
View File
@@ -10,6 +10,7 @@ import { getEndpointFileName } from '../shared/agent-hook-listener'
import { relayTestSocketPath } from './relay-test-socket-path'
const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts')
const WATCHER_TS_ENTRY = path.resolve(__dirname, '../main/ipc/parcel-watcher-process-entry.ts')
let bundleDir: string
let relayEntry: string
const spawnedSocketDirs: string[] = []
@@ -24,7 +25,17 @@ beforeAll(async () => {
target: 'node18',
format: 'cjs',
outfile: relayEntry,
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false
})
await build({
entryPoints: [WATCHER_TS_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: path.join(bundleDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false
})
}, 30_000)
@@ -0,0 +1,128 @@
import type { IBufferLine } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links'
import {
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS,
TERMINAL_HTTP_URL_MAX_LENGTH
} from './terminal-http-link-limits'
function bufferLine(text: string, onTranslateWithColumns: () => void): IBufferLine {
return {
isWrapped: false,
length: text.length,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
if (outColumns) {
onTranslateWithColumns()
}
outColumns?.push(...Array.from({ length: text.length + 1 }, (_value, index) => index))
return text.slice(startColumn, endColumn)
}
} as IBufferLine
}
function bufferLineWithCellColumns(text: string): IBufferLine {
const columns: number[] = []
let column = 0
for (const character of text) {
columns.push(column)
column += character === '你' ? 2 : 1
}
columns.push(column)
return {
isWrapped: false,
length: column,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
outColumns?.push(...columns)
return text.slice(startColumn, endColumn)
}
} as IBufferLine
}
describe('hard-wrapped terminal HTTP candidate bounds', () => {
it('builds each required column map once and rejects an overlength candidate early', () => {
const onTranslateWithColumns = vi.fn()
const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) =>
bufferLine(`| ${index === 0 ? 'http://x' : 'aaaaaaaa'} |`, onTranslateWithColumns)
)
expect(
buildHardWrappedHttpLogicalLineCandidates(
{ getLine: (y) => rows[y] },
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS
)
).toEqual([])
expect(onTranslateWithColumns).toHaveBeenCalledTimes(
Math.floor(TERMINAL_HTTP_URL_MAX_LENGTH / 'aaaaaaaa'.length) + 1
)
})
it('does not build column maps while rejecting rows without an HTTP scheme', () => {
const onTranslateWithColumns = vi.fn()
const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, () =>
bufferLine(`| ${'a'.repeat(500)} |`, onTranslateWithColumns)
)
expect(
buildHardWrappedHttpLogicalLineCandidates(
{ getLine: (y) => rows[y] },
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS
)
).toEqual([])
expect(onTranslateWithColumns).not.toHaveBeenCalled()
})
it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => {
const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|']
const rows = rowTexts.map(bufferLineWithCellColumns)
const candidates = buildHardWrappedHttpLogicalLineCandidates(
{ getLine: (y) => rows[y] },
rows.length
)
expect(candidates[0]?.text).toBe('http://a/aba你你你你你tailzzzzzzz')
})
it('reconstructs a maximum-length wide-character URL beyond the ASCII row bound', () => {
const scheme = 'http://'
const continuation = '你'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - scheme.length)
const rows = [
bufferLineWithCellColumns(`|${scheme}|`),
...continuation
.match(/.{1,3}/g)!
.map((fragment) =>
bufferLineWithCellColumns(`|${fragment}${' '.repeat(7 - fragment.length * 2)}|`)
)
]
expect(rows.length).toBeGreaterThan(Math.ceil(TERMINAL_HTTP_URL_MAX_LENGTH / scheme.length))
expect(
buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text
).toBe(scheme + continuation)
})
it('reconstructs a maximum-length URL with one-character continuation rows', () => {
const firstFragment = 'http://a'
const continuation = '/'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - firstFragment.length)
const rows = [
bufferLineWithCellColumns(`|${firstFragment}|`),
...continuation.split('').map((fragment) => bufferLineWithCellColumns(`|${fragment} |`))
]
const fullUrl = firstFragment + continuation
expect(() => new URL(fullUrl)).not.toThrow()
expect(
buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text
).toBe(fullUrl)
})
})
@@ -0,0 +1,148 @@
import type { IBufferLine } from '@xterm/xterm'
import {
TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS,
TERMINAL_HTTP_URL_MAX_LENGTH
} from './terminal-http-link-limits'
import { translateLineWithColumns, type WrappedLogicalLine } from './wrapped-terminal-link-ranges'
const HTTP_SCHEME_PATTERN = /https?:\/\//i
const HTTP_SCHEME_START_PATTERN = /^https?:\/\//i
const HTTP_FRAGMENT_PATTERN = /^[^\s"'!*(){}|\\^<>`│┃║╎╏┆┇┊┋]*/
const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/
const NON_LAYOUT_SUFFIX_PATTERN = /[^\s│┃║╎╏┆┇┊┋|]/
const HARD_WRAP_CONTINUATION_SUFFIX_PATTERN = /[/?&=#%+:-]$/
const MIN_HARD_WRAPPED_HTTP_ROWS = 3
const MIN_HARD_WRAP_FILL_RATIO = 0.8
type TranslatedLine = ReturnType<typeof translateLineWithColumns>
function buildCandidateFromStart(
buffer: { getLine(y: number): IBufferLine | undefined },
startY: number,
currentY: number,
translatedLines: Map<number, TranslatedLine>
): WrappedLogicalLine | null {
const startLine = buffer.getLine(startY)
if (!startLine) {
return null
}
const cachedStart = translatedLines.get(startY)
const startText = cachedStart?.text ?? startLine.translateToString(false)
const schemeIndex = startText.search(HTTP_SCHEME_PATTERN)
if (schemeIndex === -1 || !VERTICAL_LAYOUT_FRAME_PATTERN.test(startText.slice(0, schemeIndex))) {
return null
}
const translatedStart = cachedStart ?? translateLineWithColumns(startLine)
translatedLines.set(startY, translatedStart)
const schemeColumn = translatedStart.columns[schemeIndex]
if (schemeColumn === undefined) {
return null
}
const continuationPrefix = translatedStart.text.slice(0, schemeIndex)
let text = ''
let rightFrameColumn: number | null = null
let previousRowCanContinue = true
let startRowFilled = false
const rows: WrappedLogicalLine['rows'] = []
for (let rowY = startY; rowY < startY + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS; rowY++) {
if (rowY > startY && !previousRowCanContinue) {
break
}
const line = buffer.getLine(rowY)
if (!line) {
break
}
const translated =
rowY === startY
? translatedStart
: (translatedLines.get(rowY) ?? translateLineWithColumns(line))
translatedLines.set(rowY, translated)
if (rowY > startY && translated.text.slice(0, schemeIndex) !== continuationPrefix) {
break
}
const fragment = translated.text.slice(schemeIndex).match(HTTP_FRAGMENT_PATTERN)?.[0] ?? ''
if (!fragment || (rowY > startY && HTTP_SCHEME_START_PATTERN.test(fragment))) {
break
}
const fragmentEnd = schemeIndex + fragment.length
const layoutSuffix = translated.text.slice(fragmentEnd)
const rightFrameOffset = layoutSuffix.search(VERTICAL_LAYOUT_FRAME_PATTERN)
const currentRightFrameIndex = rightFrameOffset === -1 ? -1 : fragmentEnd + rightFrameOffset
const currentRightFrameColumn = translated.columns[currentRightFrameIndex]
if (
currentRightFrameIndex === -1 ||
currentRightFrameColumn === undefined ||
(rightFrameColumn !== null && currentRightFrameColumn !== rightFrameColumn) ||
NON_LAYOUT_SUFFIX_PATTERN.test(layoutSuffix)
) {
break
}
rightFrameColumn ??= currentRightFrameColumn
if (text.length + fragment.length > TERMINAL_HTTP_URL_MAX_LENGTH) {
return null
}
rows.push({
y: rowY,
text: fragment,
sourceText: translated.text,
columns: translated.columns.slice(schemeIndex, fragmentEnd + 1),
startIndex: text.length,
isWrapped: line.isWrapped,
lineLength: line.length
})
text += fragment
const contentWidth = currentRightFrameColumn - schemeColumn
const fragmentWidth = translated.columns[fragmentEnd]! - schemeColumn
const fillsRow = contentWidth > 0 && fragmentWidth / contentWidth >= MIN_HARD_WRAP_FILL_RATIO
if (rowY === startY) {
startRowFilled = fillsRow
}
previousRowCanContinue = HARD_WRAP_CONTINUATION_SUFFIX_PATTERN.test(fragment) || fillsRow
}
if (rows.length > 1 && (rows.length < MIN_HARD_WRAPPED_HTTP_ROWS || !startRowFilled)) {
// Why: a short complete URL can legitimately end in `/`; one adjacent
// framed token is not enough evidence that the URL continued onto it.
rows.splice(1)
text = rows[0]?.text ?? ''
}
if (rows.at(-1)?.y === undefined || rows.at(-1)!.y < currentY) {
return null
}
return {
text,
rows,
fingerprint: `hard-http:${rows.map((row) => `${row.y}:${row.sourceText}`).join('\0')}`
}
}
export function buildHardWrappedHttpLogicalLineCandidates(
buffer: { getLine(y: number): IBufferLine | undefined },
bufferLineNumber: number
): WrappedLogicalLine[] {
const currentY = bufferLineNumber - 1
const currentLine = buffer.getLine(currentY)
if (!currentLine || !VERTICAL_LAYOUT_FRAME_PATTERN.test(currentLine.translateToString(false))) {
// Why: cursor-positioned hard wraps require a stable vertical frame; most
// terminal clicks can avoid the bounded backward scan entirely.
return []
}
const candidates: WrappedLogicalLine[] = []
const translatedLines = new Map<number, TranslatedLine>()
const minY = Math.max(0, currentY - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + 1)
for (let startY = currentY; startY >= minY; startY--) {
const candidate = buildCandidateFromStart(buffer, startY, currentY, translatedLines)
if (candidate) {
candidates.push(candidate)
}
}
return candidates.sort(
(left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length
)
}
@@ -1,4 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import en from '@/i18n/locales/en.json'
import es from '@/i18n/locales/es.json'
import ja from '@/i18n/locales/ja.json'
import ko from '@/i18n/locales/ko.json'
import zh from '@/i18n/locales/zh.json'
import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast'
@@ -41,6 +46,7 @@ describe('showOsc52ClipboardBlockedToast', () => {
showOsc52ClipboardBlockedToast()
expect(toastInfoMock.mock.calls[0]?.[1]?.description).toContain('Grok')
const options = toastInfoMock.mock.calls[0]?.[1]
expect(options).toMatchObject({
action: {
@@ -67,4 +73,14 @@ describe('showOsc52ClipboardBlockedToast', () => {
expect(toastInfoMock).toHaveBeenCalledTimes(1)
})
it('mentions Grok in every supported locale', () => {
const locales = [en, es, ja, ko, zh]
for (const locale of locales) {
expect(
locale.auto.components.terminal.pane.osc52.clipboard.blocked.toast['7cf51f74fd']
).toContain('Grok')
}
})
})
@@ -19,7 +19,7 @@ export function showOsc52ClipboardBlockedToast(): void {
{
description: translate(
'auto.components.terminal.pane.osc52.clipboard.blocked.toast.7cf51f74fd',
'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.'
'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.'
),
duration: 12_000,
action: {
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('isTerminalHttpLinkActivation', () => {
it('leaves Alt+Cmd gestures to the child TUI on macOS', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
expect(
isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: false } as MouseEvent)
).toBe(true)
expect(
isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: true } as MouseEvent)
).toBe(false)
})
it('leaves Alt+Ctrl gestures to the child TUI on other platforms', () => {
vi.stubGlobal('navigator', { userAgent: 'Windows' })
expect(
isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: false } as MouseEvent)
).toBe(true)
expect(
isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: true } as MouseEvent)
).toBe(false)
})
})
@@ -0,0 +1,7 @@
import { isTerminalLinkActivation } from './terminal-link-activation'
export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean {
// Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY,
// so plain HTTP link handling must leave those gestures to the child TUI.
return Boolean(event && !event.altKey && isTerminalLinkActivation(event))
}
@@ -0,0 +1,5 @@
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
// Why: punctuation can form a valid one-character continuation row, so the
// URL length limit is also the only complete finite bound on framed rows.
export const TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS = TERMINAL_HTTP_URL_MAX_LENGTH
@@ -0,0 +1,59 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
export function installTerminalLinkPtyMouseSuppression(
terminal: Terminal,
shouldSuppressMouseEvent: (event: MouseEvent) => boolean
): IDisposable {
const terminalElement = terminal.element
const ownerDocument = terminalElement?.ownerDocument
const ownerWindow = ownerDocument?.defaultView
let previousMouseEventsRequireAlt: boolean | null = null
let restoreQueued = false
const restore = (): void => {
restoreQueued = false
if (previousMouseEventsRequireAlt === null) {
return
}
terminal.options.mouseEventsRequireAlt = previousMouseEventsRequireAlt
previousMouseEventsRequireAlt = null
ownerDocument?.removeEventListener('mouseup', queueRestore)
ownerWindow?.removeEventListener('blur', restore)
}
const queueRestore = (): void => {
if (restoreQueued || previousMouseEventsRequireAlt === null) {
return
}
restoreQueued = true
queueMicrotask(restore)
}
const handleMouseDown = (event: MouseEvent): void => {
if (
event.button !== 0 ||
!isTerminalHttpLinkActivation(event) ||
!shouldSuppressMouseEvent(event)
) {
return
}
restore()
previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt)
// Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware
// TUI, letting the terminal and the child process both open the URL.
terminal.options.mouseEventsRequireAlt = true
ownerDocument?.addEventListener('mouseup', queueRestore)
ownerWindow?.addEventListener('blur', restore)
}
terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
terminalElement?.addEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS)
return {
dispose: () => {
restore()
terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
terminalElement?.removeEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS)
}
}
}
@@ -0,0 +1,29 @@
import type { Terminal } from '@xterm/xterm'
export function getTerminalBufferPositionForMouseEvent(
terminal: Terminal,
event: MouseEvent
): { x: number; y: number } | null {
const screenElement = terminal.element?.querySelector('.xterm-screen')
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
return null
}
const rect = screenElement.getBoundingClientRect()
const relativeX = event.clientX - rect.left
const relativeY = event.clientY - rect.top
if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) {
return null
}
const cellWidth = rect.width / terminal.cols
const cellHeight = rect.height / terminal.rows
if (cellWidth <= 0 || cellHeight <= 0) {
return null
}
return {
x: Math.floor(relativeX / cellWidth) + 1,
y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1
}
}
@@ -0,0 +1,429 @@
import type { IBufferLine, Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
const COLS = 157
const ROWS = 59
const INDENT = ''
const FULL_URL = [
'http://127.0.0.1:8765/orca-double-open-repro-wrapped/',
Array.from({ length: 79 }, (_value, index) => `seg${String(index + 1).padStart(4, '0')}`).join(
'/'
),
'?marker=wrap-test&n=001&pad=',
'x'.repeat(120)
].join('')
const URL_ROWS = Array.from({ length: Math.ceil(FULL_URL.length / COLS) }, (_value, index) =>
FULL_URL.slice(index * COLS, (index + 1) * COLS)
)
const FRAMED_ROW_STARTS = [
0,
FULL_URL.indexOf('seg0008/'),
FULL_URL.indexOf('seg0022/'),
FULL_URL.indexOf('seg0036/'),
FULL_URL.indexOf('seg0050/'),
FULL_URL.indexOf('seg0064/'),
FULL_URL.indexOf('seg0078/'),
FULL_URL.indexOf('test&n=001'),
FULL_URL.length - 19
]
const FRAMED_URL_ROWS = FRAMED_ROW_STARTS.map((start, index) =>
FULL_URL.slice(start, FRAMED_ROW_STARTS[index + 1])
)
const openUrlMock = vi.fn()
type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined]
function makeBufferLine(
fragment: string,
options: { cols?: number; prefix?: string; suffix?: string; isWrapped?: boolean } = {}
): IBufferLine {
const cols = options.cols ?? COLS
const prefix = options.prefix ?? INDENT
const suffix = options.suffix ?? ''
const text = `${prefix}${fragment}`.padEnd(cols - suffix.length) + suffix
return {
isWrapped: options.isWrapped ?? false,
length: cols,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = text.length,
outColumns?: number[]
) => {
if (outColumns) {
outColumns.splice(
0,
outColumns.length,
...Array.from(
{ length: endColumn - startColumn + 1 },
(_value, index) => index + startColumn
)
)
}
return text.slice(startColumn, endColumn)
}
} as IBufferLine
}
function makeTerminal(options?: {
cols?: number
rows?: number
urlRows?: string[]
linePrefix?: string
lineSuffix?: string
softWrapped?: boolean
}): {
terminal: Terminal
registrations: ListenerRegistration[]
clearSelection: ReturnType<typeof vi.fn>
} {
const cols = options?.cols ?? COLS
const rows = options?.rows ?? ROWS
const urlRows = options?.urlRows ?? URL_ROWS
const registrations: ListenerRegistration[] = []
const ownerWindow = {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const ownerDocument = {
defaultView: ownerWindow,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const screen = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: cols * 10, height: rows * 10 })
}
const element = {
ownerDocument,
querySelector: vi.fn(() => screen),
addEventListener: vi.fn(
(name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => {
registrations.push([name, listener, options])
}
),
removeEventListener: vi.fn()
}
const clearSelection = vi.fn()
return {
terminal: {
cols,
rows,
options: { mouseEventsRequireAlt: false },
element,
buffer: {
active: {
viewportY: 0,
getLine: (y: number) =>
urlRows[y] &&
makeBufferLine(urlRows[y], {
cols,
prefix: options?.linePrefix,
suffix: options?.lineSuffix,
isWrapped: (options?.softWrapped ?? true) && y > 0
})
}
},
clearSelection
} as unknown as Terminal,
registrations,
clearSelection
}
}
function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent {
let defaultPrevented = false
return {
button: 0,
metaKey: true,
ctrlKey: false,
altKey: options.altKey ?? false,
shiftKey: true,
get defaultPrevented() {
return defaultPrevented
},
clientX: 150,
clientY: row * 10 + 5,
preventDefault: vi.fn(() => {
defaultPrevented = true
})
} as unknown as MouseEvent
}
describe('hard-wrapped terminal HTTP clicks', () => {
beforeEach(() => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } })
registerHttpLinkStoreAccessor(() => ({
settings: { openLinksInApp: false },
setActiveWorktree: vi.fn(),
createBrowserTab: vi.fn()
}))
openUrlMock.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('opens the full logical URL once when WebLinksAddon reports only the first row', () => {
const { terminal, registrations, clearSelection } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const event = mouseEventForRow(0)
expect(
handleTerminalWebLinkClick(URL_ROWS[0], event, {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
const fallback = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1]
expect(fallback).toBeDefined()
fallback!(event)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
expect(new URL(URL_ROWS[0]).pathname).toHaveLength(136)
expect(`${new URL(FULL_URL).pathname}${new URL(FULL_URL).search}`).toHaveLength(811)
expect(event.preventDefault).toHaveBeenCalled()
expect(clearSelection).toHaveBeenCalled()
disposable.dispose()
})
it('opens the same full URL from a continuation-row fallback click', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const event = mouseEventForRow(3)
const fallback = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1]
fallback!(event)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
disposable.dispose()
})
it('reconstructs a URL split across cursor-positioned rows inside a TUI frame', () => {
const { terminal } = makeTerminal({
cols: 135,
urlRows: FRAMED_URL_ROWS,
linePrefix: ' │ ',
lineSuffix: '│ ',
softWrapped: false
})
const event = mouseEventForRow(0)
expect(
handleTerminalWebLinkClick(FRAMED_URL_ROWS[0], event, {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
expect(new URL(FRAMED_URL_ROWS[0]).pathname).toHaveLength(88)
})
it('reconstructs a URL that fills each cursor-positioned TUI row up to its frame', () => {
const cols = 135
const linePrefix = ' │ '
const lineSuffix = '│ '
const contentWidth = cols - linePrefix.length - lineSuffix.length
const fullWidthRows = Array.from(
{ length: Math.ceil(FULL_URL.length / contentWidth) },
(_value, index) => FULL_URL.slice(index * contentWidth, (index + 1) * contentWidth)
)
const { terminal } = makeTerminal({
cols,
urlRows: fullWidthRows,
linePrefix,
lineSuffix,
softWrapped: false
})
expect(
handleTerminalWebLinkClick(fullWidthRows[0], mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledTimes(1)
expect(openUrlMock).toHaveBeenCalledWith(FULL_URL)
})
it('reconstructs supported URLs spanning more than twenty framed rows', () => {
const cols = 80
const linePrefix = ' │ '
const lineSuffix = '│ '
const contentWidth = cols - linePrefix.length - lineSuffix.length
const longUrl = `http://example.com/${'a'.repeat(contentWidth * 20)}`
const urlRows = Array.from(
{ length: Math.ceil(longUrl.length / contentWidth) },
(_value, index) => longUrl.slice(index * contentWidth, (index + 1) * contentWidth)
)
const { terminal } = makeTerminal({
cols,
urlRows,
linePrefix,
lineSuffix,
softWrapped: false
})
expect(urlRows).toHaveLength(21)
expect(
handleTerminalWebLinkClick(urlRows[0], mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledWith(longUrl)
})
it('keeps nested HTTP URLs inside a wrapped query parameter', () => {
const cols = 80
const linePrefix = ' │ '
const lineSuffix = '│ '
const contentWidth = cols - linePrefix.length - lineSuffix.length
const firstRow = `http://example.com/${'a'.repeat(contentWidth - 'http://example.com/'.length)}`
const nestedQuery = 'segment?redirect=https://nested.example/path'
const secondRow = `${nestedQuery}${'b'.repeat(contentWidth - nestedQuery.length)}`
const urlRows = [firstRow, secondRow, 'tail']
const fullUrl = urlRows.join('')
const { terminal } = makeTerminal({
cols,
urlRows,
linePrefix,
lineSuffix,
softWrapped: false
})
expect(
handleTerminalWebLinkClick(firstRow, mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledWith(fullUrl)
})
it('does not append an unrelated aligned TUI row to a complete URL', () => {
const { terminal, registrations } = makeTerminal({
cols: 135,
urlRows: ['http://example.com/', 'next-token'],
linePrefix: ' │ ',
lineSuffix: '│ ',
softWrapped: false
})
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
expect(
handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledOnce()
expect(openUrlMock).toHaveBeenCalledWith('http://example.com/')
openUrlMock.mockReset()
const fallback = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1]
fallback!(mouseEventForRow(1))
expect(openUrlMock).not.toHaveBeenCalled()
disposable.dispose()
})
it('does not join a complete URL to multiple unrelated framed rows', () => {
const unrelatedFilledRow = 'a'.repeat(103)
const { terminal } = makeTerminal({
cols: 110,
urlRows: ['http://example.com/', unrelatedFilledRow, 'unrelated'],
linePrefix: ' │ ',
lineSuffix: '│ ',
softWrapped: false
})
expect(
handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledOnce()
expect(openUrlMock).toHaveBeenCalledWith('http://example.com/')
})
it('does not suppress a modifier-click when the buffer position is not an HTTP link', () => {
const { terminal, registrations } = makeTerminal({ urlRows: ['not-a-link'] })
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
mouseDown!(mouseEventForRow(0))
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
disposable.dispose()
})
it('leaves Alt-modified link gestures to the child TUI', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
const event = mouseEventForRow(0, { altKey: true })
mouseDown!(event)
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
expect(
handleTerminalWebLinkClick(URL_ROWS[0], event, {
terminal,
worktreeId: 'wt-1',
worktreePath: '/tmp',
startupCwd: '/tmp'
})
).toBe(false)
expect(openUrlMock).not.toHaveBeenCalled()
disposable.dispose()
})
it('temporarily suppresses PTY mouse reporting at an HTTP link position', async () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
const mouseUp = registrations.find(
([name, _listener, options]) => name === 'mouseup' && options !== undefined
)?.[1]
mouseDown!(mouseEventForRow(0))
expect(terminal.options.mouseEventsRequireAlt).toBe(true)
mouseUp!(mouseEventForRow(0))
await Promise.resolve()
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
disposable.dispose()
})
})
@@ -1,8 +1,15 @@
import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm'
import { openHttpLink } from '@/lib/http-link-routing'
import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing'
import { isTerminalLinkActivation } from './terminal-link-activation'
import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
import {
buildCandidateLogicalLinesForBufferPosition,
dedupeLogicalLines
} from './terminal-file-link-hit-testing'
import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression'
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
type UrlLinkHitTestDeps = {
worktreeId: string
@@ -26,7 +33,7 @@ type ParsedTerminalHttpLink = {
}
const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] {
const links: ParsedTerminalHttpLink[] = []
@@ -56,7 +63,7 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
// Why: desktop terminal links require an intentional Cmd/Ctrl gesture so
// plain clicks remain available for cursor placement and selection. Mobile
// tap routing is handled separately under mobile/src/terminal.
return isTerminalLinkActivation(event)
return isTerminalHttpLinkActivation(event)
}
function* iterateTerminalHttpUrlCandidates(
@@ -185,56 +192,40 @@ function isAsciiWordCode(code: number): boolean {
)
}
function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
return terminal.element?.querySelector('.xterm-screen') ?? null
}
function getBufferPositionForTerminalMouseEvent(
export function openHttpLinkAtTerminalMouseEvent(
terminal: Terminal,
event: MouseEvent
): { x: number; y: number } | null {
const screenElement = getTerminalScreenElement(terminal)
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
return null
event: MouseEvent,
deps: UrlLinkHitTestDeps
): boolean {
if (event.button !== 0 || !isTerminalHttpLinkActivation(event)) {
return false
}
const rect = screenElement.getBoundingClientRect()
const relativeX = event.clientX - rect.left
const relativeY = event.clientY - rect.top
if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) {
return null
}
const cellWidth = rect.width / terminal.cols
const cellHeight = rect.height / terminal.rows
if (cellWidth <= 0 || cellHeight <= 0) {
return null
}
return {
x: Math.floor(relativeX / cellWidth) + 1,
y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
if (!position) {
return false
}
return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps)
}
export function installHttpLinkClickFallback(
terminal: Terminal,
deps: UrlLinkClickFallbackDeps
): IDisposable {
const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => {
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
return Boolean(
position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
)
})
const handleMouseUp = (event: MouseEvent): void => {
if (!isDesktopHttpLinkFallbackActivation(event)) {
return
}
const position = getBufferPositionForTerminalMouseEvent(terminal, event)
if (!position) {
return
}
// Why: xterm's WebLinksAddon only activates after hover state exists. This
// direct mouseup fallback preserves modifier-clicks when the hover link was
// never established, while defaultPrevented avoids duplicate opens.
const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, {
const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, {
worktreeId: deps.worktreeId,
forceSystemBrowser: event.shiftKey,
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
@@ -249,6 +240,7 @@ export function installHttpLinkClickFallback(
terminalElement?.addEventListener('mouseup', handleMouseUp)
return {
dispose: () => {
ptyMouseSuppression.dispose()
terminalElement?.removeEventListener('mouseup', handleMouseUp)
}
}
@@ -260,10 +252,30 @@ export function openHttpLinkAtBufferPosition(
terminalColumns: number,
deps: UrlLinkHitTestDeps
): boolean {
const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
if (logicalLines.length === 0) {
const url = findHttpLinkAtBufferPosition(buffer, position, terminalColumns)
if (!url) {
return false
}
openTerminalHttpLink(url, deps)
return true
}
function findHttpLinkAtBufferPosition(
buffer: { getLine(y: number): IBufferLine | undefined },
position: { x: number; y: number },
terminalColumns: number
): string | null {
const nativeWrappedLogicalLine = buildWrappedLogicalLine(buffer, position.y)
const logicalLines = dedupeLogicalLines([
...(nativeWrappedLogicalLine && nativeWrappedLogicalLine.rows.length > 1
? [nativeWrappedLogicalLine]
: []),
...buildHardWrappedHttpLogicalLineCandidates(buffer, position.y),
...buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
])
if (logicalLines.length === 0) {
return null
}
for (const logicalLine of logicalLines) {
for (const parsed of extractTerminalHttpLinks(logicalLine.text)) {
@@ -271,12 +283,11 @@ export function openHttpLinkAtBufferPosition(
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
continue
}
openTerminalHttpLink(parsed.url, deps)
return true
return parsed.url
}
}
return false
return null
}
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
@@ -0,0 +1,50 @@
import type { Terminal } from '@xterm/xterm'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
import { handleOscLink } from './terminal-osc-link-routing'
import {
openHttpLinkAtTerminalMouseEvent,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
type TerminalWebLinkClickDeps = Pick<
LinkHandlerDeps,
'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath'
> & {
terminal: Terminal | null
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
export function handleTerminalWebLinkClick(
url: string,
event: MouseEvent | undefined,
deps: TerminalWebLinkClickDeps
): boolean {
if (!event || !isTerminalHttpLinkActivation(event)) {
return false
}
let handled: boolean
if (
deps.terminal &&
openHttpLinkAtTerminalMouseEvent(deps.terminal, event, {
worktreeId: deps.worktreeId,
forceSystemBrowser: Boolean(event.shiftKey),
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
) {
// Why: WebLinksAddon only knows the physical row; Orca's logical hit-test
// preserves the complete URL rendered across hard-wrapped TUI rows.
event.preventDefault()
handled = true
} else {
handled = handleOscLink(url, event, deps)
}
if (handled) {
// Why: link navigation can steal focus before xterm's mouseup cleanup;
// clearing selection also detaches its pending drag-selection listeners.
deps.terminal?.clearSelection()
}
return handled
}
@@ -31,6 +31,7 @@ import {
import { createTerminalHandleLinkProvider } from './terminal-handle-links'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { handleOscLink } from './terminal-osc-link-routing'
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
import {
installHttpLinkClickFallback,
type TerminalLinkRoutingPreferenceRequester
@@ -1480,28 +1481,16 @@ export function useTerminalPaneLifecycle({
terminalTuiScrollSensitivity: () =>
normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity),
onLinkClick: (event, url) => {
if (!event) {
return
}
const activePane = managerRef.current?.getActivePane()
const handled = handleOscLink(url, event, {
handleTerminalWebLinkClick(url, event, {
...linkDeps,
terminal: activePane?.terminal ?? null,
startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd,
runtimeEnvironmentId: activePane
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
: null,
requestOpenLinksInAppPreference
})
// Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus
// away from the terminal before the click's mouseup reaches
// ownerDocument. That leaves xterm's SelectionService drag-select
// mousemove listener attached, so subsequent mouse motion extends a
// phantom selection until the next click/Esc. Explicitly clearing the
// selection also detaches those listeners (see
// SelectionService._removeMouseDownListeners).
if (handled) {
managerRef.current?.getActivePane()?.terminal.clearSelection()
}
},
formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint),
// Why: TerminalPane instances stay mounted for hidden visited worktrees
@@ -65,7 +65,7 @@ function translateLineWithCells(line: IBufferLine): { text: string; columns: num
return { text, columns }
}
function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } {
export function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } {
const columns: number[] = []
const text = (line as TerminalBufferLineWithColumns).translateToString(
false,
+1 -1
View File
@@ -2588,7 +2588,7 @@
"blocked": {
"toast": {
"97c98f1afe": "Open Setting",
"7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.",
"7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.",
"89eaa3e80b": "Terminal clipboard write blocked"
}
}
+1 -1
View File
@@ -2588,7 +2588,7 @@
"blocked": {
"toast": {
"97c98f1afe": "Abrir ajustes",
"7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim o fzf.",
"7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim, fzf o Grok.",
"89eaa3e80b": "Escritura al portapapeles del terminal bloqueada"
}
}
+1 -1
View File
@@ -2588,7 +2588,7 @@
"blocked": {
"toast": {
"97c98f1afe": "設定を開く",
"7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。",
"7cf51f74fd": "SSH、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。",
"89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました"
}
}
+1 -1
View File
@@ -2588,7 +2588,7 @@
"blocked": {
"toast": {
"97c98f1afe": "설정 열기",
"7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.",
"7cf51f74fd": "SSH, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.",
"89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다."
}
}
+1 -1
View File
@@ -2588,7 +2588,7 @@
"blocked": {
"toast": {
"97c98f1afe": "打开设置",
"7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。",
"7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim、fzf 或 Grok 进行复制。",
"89eaa3e80b": "终端剪贴板写入被阻止"
}
}