diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e29a7426371..8485d21a245 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -21,8 +21,6 @@ inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts inline src/main/linear/issues.ts -inline src/main/ports/advertised-url-watcher.ts -inline src/main/ports/local-workspace-port-scanner.ts inline src/main/providers/local-pty-provider.ts inline src/main/rate-limits/service.ts inline src/main/runtime/orca-runtime-browser.ts @@ -84,7 +82,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts inline src/shared/keybindings.ts inline src/shared/telemetry-events.ts -inline tests/e2e/helpers/terminal.ts mobile-config app/h/*/files/*.tsx mobile-config app/h/*/index.tsx mobile-config app/h/*/session/*.tsx diff --git a/src/main/ports/advertised-url-cache-update.ts b/src/main/ports/advertised-url-cache-update.ts new file mode 100644 index 00000000000..210cd0c1966 --- /dev/null +++ b/src/main/ports/advertised-url-cache-update.ts @@ -0,0 +1,95 @@ +import { + cacheKey, + classifyHost, + dedupeChangeEvents, + formatHostForOrigin, + isDefaultPort, + isUnspecifiedHost, + shouldReplace, + worktreeIdFromCacheKey, + type CacheKey, + type ListenerScanState +} from './advertised-url-parsing' +import type { AdvertisedUrl, AdvertisedUrlChangeEvent } from './advertised-url-watcher' + +export function considerAdvertisedUrl(args: { + url: URL + ptyId: string + worktreeId: string + timestamp: number + cache: Map + validationBaselines: Map + startupAbsentAllowances: Set + currentScanState: ListenerScanState | undefined + maxCacheEntries: number +}): AdvertisedUrlChangeEvent[] { + const protocol = args.url.protocol === 'https:' ? 'https' : 'http' + const port = args.url.port ? Number(args.url.port) : protocol === 'https' ? 443 : 80 + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return [] + } + const hostname = args.url.hostname + if (isUnspecifiedHost(hostname)) { + return [] + } + const candidate: AdvertisedUrl = { + origin: `${protocol}://${formatHostForOrigin(args.url)}${isDefaultPort(protocol, port) ? '' : `:${port}`}`, + host: hostname, + hostKind: classifyHost(hostname), + protocol, + port, + ptyId: args.ptyId, + lastSeenAt: args.timestamp + } + const key = cacheKey(args.worktreeId, port) + const existing = args.cache.get(key) + if (existing && !shouldReplace(existing, candidate)) { + existing.lastSeenAt = args.timestamp + return [] + } + + args.cache.set(key, candidate) + if (args.currentScanState) { + args.validationBaselines.set(key, args.currentScanState) + if (args.currentScanState.kind === 'absent') { + args.startupAbsentAllowances.add(key) + } else { + args.startupAbsentAllowances.delete(key) + } + } else { + args.validationBaselines.delete(key) + args.startupAbsentAllowances.add(key) + } + const changedEvents = enforceAdvertisedUrlCacheLimit(args) + if (!existing || existing.origin !== candidate.origin) { + changedEvents.push({ worktreeId: args.worktreeId, port }) + } + return dedupeChangeEvents(changedEvents) +} + +function enforceAdvertisedUrlCacheLimit(args: { + cache: Map + validationBaselines: Map + startupAbsentAllowances: Set + maxCacheEntries: number +}): AdvertisedUrlChangeEvent[] { + if (args.cache.size <= args.maxCacheEntries) { + return [] + } + const entries = Array.from(args.cache.entries()).sort( + (left, right) => left[1].lastSeenAt - right[1].lastSeenAt + ) + const overflow = args.cache.size - args.maxCacheEntries + const removedEvents: AdvertisedUrlChangeEvent[] = [] + for (let index = 0; index < overflow; index++) { + const [key, entry] = entries[index] + args.cache.delete(key) + args.validationBaselines.delete(key) + args.startupAbsentAllowances.delete(key) + removedEvents.push({ + worktreeId: worktreeIdFromCacheKey(key, entry.port), + port: entry.port + }) + } + return removedEvents +} diff --git a/src/main/ports/advertised-url-parsing.ts b/src/main/ports/advertised-url-parsing.ts new file mode 100644 index 00000000000..1d342e8e03b --- /dev/null +++ b/src/main/ports/advertised-url-parsing.ts @@ -0,0 +1,283 @@ +/* eslint-disable no-control-regex -- Terminal control-sequence parsing intentionally matches raw control bytes. */ +import type { + AdvertisedUrl, + AdvertisedUrlChangeEvent, + AdvertisedUrlListenerObservation, + HostKind +} from './advertised-url-watcher' + +const PER_PTY_BUFFER_LIMIT = 4096 +export const PENDING_PRE_BIND_LIMIT = 16 * 1024 +/** Cap on distinct never-bound PTY IDs; spawn-failure paths never bindPty, so without a bound they'd leak one entry each. */ +export const MAX_PENDING_ENTRIES = 32 +export const MAX_CACHE_ENTRIES = 256 +const URL_CANDIDATE_LIMIT = 2048 + +// ANSI/OSC strippers mirror the runtime normalizer in src/main/runtime/orca-runtime.ts, plus URL-specific cursor-move handling to avoid fusing skipped text. +const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g +// Why: cursor moves in differential redraws skip on-screen cells; a URL-invalid guard drops the damaged candidate. +const CURSOR_MOVE_PATTERN = /\x1b\[[0-?]*[ -/]*[CDGHf]/g +const CURSOR_MOVE_URL_GUARD = '[' +const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g +const SINGLE_ESC_PATTERN = /\x1b[@-_]/g +const CONTROL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g + +// Permissive matcher (real validation is `new URL()` below); stops at non-URL chars so terminal punctuation isn't absorbed. +const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi + +export type CacheKey = string +export type ListenerScanState = { kind: 'absent' } | { kind: 'present'; pid?: number } + +export function cacheKey(worktreeId: string, port: number): CacheKey { + return `${worktreeId}::${port}` +} + +export function worktreeIdFromCacheKey(key: CacheKey, port: number): string { + const suffix = `::${port}` + return key.endsWith(suffix) ? key.slice(0, -suffix.length) : key +} + +export class PtyBuffer { + private raw = '' + + /** Append a chunk; return cleaned text up to the last newline. The tail stays buffered so a URL or ANSI sequence split across chunks survives. */ + ingest(chunk: string): string { + const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') + // Keep the suffix directly so oversized chunks never materialize a throwaway full concatenation. + if (chunk.length >= PER_PTY_BUFFER_LIMIT) { + this.raw = chunk.slice(-PER_PTY_BUFFER_LIMIT) + } else if (this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT) { + this.raw = `${this.raw.slice(-(PER_PTY_BUFFER_LIMIT - chunk.length))}${chunk}` + } else { + this.raw += chunk + } + if (!chunkHasLineBreak) { + return '' + } + const lastNewline = lastLineBreak(this.raw) + if (lastNewline === -1) { + return '' + } + const finalized = this.raw.slice(0, lastNewline + 1) + this.raw = this.raw.slice(lastNewline + 1) + return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : '' + } +} + +function mayContainHttpUrl(text: string): boolean { + // Control stripping cannot create any character required by an HTTP scheme. + return ( + (text.includes('h') || text.includes('H')) && + (text.includes('t') || text.includes('T')) && + (text.includes('p') || text.includes('P')) && + text.includes(':') && + text.includes('/') + ) +} + +function lastLineBreak(text: string): number { + // Accept either \n or \r as a finalize point (\r\n is normalized later in stripTerminalControls). + for (let i = text.length - 1; i >= 0; i--) { + const ch = text.charCodeAt(i) + if (ch === 0x0a || ch === 0x0d) { + return i + } + } + return -1 +} + +export function stripTerminalControls(text: string): string { + return text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(OSC_PATTERN, '') + .replace(CURSOR_MOVE_PATTERN, CURSOR_MOVE_URL_GUARD) + .replace(CSI_PATTERN, '') + .replace(SINGLE_ESC_PATTERN, '') + .replace(CONTROL_PATTERN, '') +} + +export function extractUrlCandidates(cleaned: string): URL[] { + const results: URL[] = [] + for (const match of cleaned.matchAll(URL_CANDIDATE_PATTERN)) { + let candidate = match[0] + if (candidate.length > URL_CANDIDATE_LIMIT) { + continue + } + // Strip common trailing punctuation that cannot end a real URL. + while (candidate.length > 0 && /[.,;:!?)\]}>'"`]/.test(candidate.slice(-1))) { + candidate = candidate.slice(0, -1) + } + const url = parseUrl(candidate) + if (url) { + results.push(url) + } + } + return results +} + +function parseUrl(candidate: string): URL | null { + try { + const url = new URL(candidate) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + if (!url.hostname) { + return null + } + return url + } catch { + return null + } +} + +export function classifyHost(hostname: string): HostKind { + // Why: strip IPv6 brackets so this public API accepts both "[::1]" (Node's form) and bare literals. + const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { + return 'loopback' + } + if (isIpv4(lower)) { + if (isPrivateIpv4(lower)) { + return 'private-ip' + } + return 'public-ip' + } + if (isIpv6(lower)) { + if (isPrivateIpv6(lower)) { + return 'private-ip' + } + return 'public-ip' + } + // Anything else is a DNS name — that's what we prefer for dev servers. + return 'custom' +} + +function isIpv4(value: string): boolean { + const parts = value.split('.') + if (parts.length !== 4) { + return false + } + return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255) +} + +function isPrivateIpv4(value: string): boolean { + const [a, b] = value.split('.').map((n) => Number(n)) + // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local) + if (a === 10) { + return true + } + if (a === 172 && b >= 16 && b <= 31) { + return true + } + if (a === 192 && b === 168) { + return true + } + if (a === 169 && b === 254) { + return true + } + return false +} + +export function isUnspecifiedHost(hostname: string): boolean { + const stripped = hostname.toLowerCase().replace(/^\[|\]$/g, '') + return stripped === '0.0.0.0' || stripped === '::' || stripped === '*' +} + +function isIpv6(value: string): boolean { + // url.hostname for IPv6 returns lowercase without brackets — quick sniff. + return value.includes(':') && /^[0-9a-f:]+$/.test(value) +} + +function isPrivateIpv6(value: string): boolean { + // fc00::/7 (ULA) and fe80::/10 (link-local) + if (value.startsWith('fc') || value.startsWith('fd')) { + return true + } + const firstHextet = Number.parseInt(value.split(':', 1)[0], 16) + return Number.isFinite(firstHextet) && (firstHextet & 0xffc0) === 0xfe80 +} + +function hostKindScore(kind: HostKind): number { + // Prefer custom DNS > loopback > private IP > public IP: loopback beats LAN for cert/cookie reasons on one machine. + switch (kind) { + case 'custom': + return 3 + case 'loopback': + return 2 + case 'private-ip': + return 1 + case 'public-ip': + return 0 + } +} + +export function shouldReplace(existing: AdvertisedUrl, candidate: AdvertisedUrl): boolean { + const oldScore = hostKindScore(existing.hostKind) + const newScore = hostKindScore(candidate.hostKind) + if (newScore !== oldScore) { + return newScore > oldScore + } + if (existing.protocol !== candidate.protocol) { + return candidate.protocol === 'https' + } + return candidate.lastSeenAt >= existing.lastSeenAt +} + +export function isDefaultPort(protocol: 'http' | 'https', port: number): boolean { + return (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) +} + +export function formatHostForOrigin(url: URL): string { + // Why: some JS runtimes strip the IPv6 brackets Node adds; re-bracket a bare IPv6 literal. + const h = url.hostname + if (h.startsWith('[') && h.endsWith(']')) { + return h + } + if (h.includes(':')) { + return `[${h}]` + } + return h +} + +export function observedListenersByPort( + observations: readonly AdvertisedUrlListenerObservation[] +): Map { + const observed = new Map() + for (const observation of observations) { + const existing = observed.get(observation.port) + if (!observed.has(observation.port)) { + observed.set(observation.port, observation.pid) + } else if (existing !== observation.pid) { + // Multiple host-specific listeners on one port make PID attribution ambiguous; keep presence only. + observed.set(observation.port, undefined) + } + } + return observed +} + +export function scanStateChanged(previous: ListenerScanState, current: ListenerScanState): boolean { + if (previous.kind !== current.kind) { + return true + } + if (previous.kind === 'absent' || current.kind === 'absent') { + return false + } + return previous.pid !== undefined && current.pid !== undefined && previous.pid !== current.pid +} + +export function dedupeChangeEvents( + events: readonly AdvertisedUrlChangeEvent[] +): AdvertisedUrlChangeEvent[] { + const seen = new Set() + const deduped: AdvertisedUrlChangeEvent[] = [] + for (const event of events) { + const key = cacheKey(event.worktreeId, event.port) + if (seen.has(key)) { + continue + } + seen.add(key) + deduped.push(event) + } + return deduped +} diff --git a/src/main/ports/advertised-url-reconciliation.ts b/src/main/ports/advertised-url-reconciliation.ts new file mode 100644 index 00000000000..e5552b858f8 --- /dev/null +++ b/src/main/ports/advertised-url-reconciliation.ts @@ -0,0 +1,87 @@ +import { + cacheKey, + scanStateChanged, + shouldReplace, + type CacheKey, + type ListenerScanState +} from './advertised-url-parsing' +import type { AdvertisedUrl } from './advertised-url-watcher' + +export function shouldEvictAdvertisedUrlAfterScan(args: { + key: CacheKey + entry: AdvertisedUrl + current: ListenerScanState + validationBaselines: Map + startupAbsentAllowances: Set +}): boolean { + const baseline = args.validationBaselines.get(args.key) + if (args.current.kind === 'absent') { + if ( + args.entry.validatedListenerPid === undefined && + baseline?.kind !== 'present' && + args.startupAbsentAllowances.delete(args.key) + ) { + return false + } + return true + } + if ( + args.entry.validatedListenerPid !== undefined && + args.current.pid !== undefined && + args.entry.validatedListenerPid !== args.current.pid + ) { + return true + } + if (baseline?.kind === 'absent') { + args.startupAbsentAllowances.delete(args.key) + return false + } + return ( + args.entry.validatedListenerPid === undefined && + baseline !== undefined && + scanStateChanged(baseline, args.current) + ) +} + +export function lookupBestAdvertisedUrl(args: { + worktreeIds: readonly string[] + port: number + currentListenerPid?: number + cache: Map + validationBaselines: Map + startupAbsentAllowances: Set + onEvict: (worktreeId: string) => void +}): AdvertisedUrl | undefined { + let best: { worktreeId: string; entry: AdvertisedUrl } | undefined + for (const worktreeId of args.worktreeIds) { + const key = cacheKey(worktreeId, args.port) + const candidate = args.cache.get(key) + if (!candidate) { + continue + } + if ( + args.currentListenerPid !== undefined && + candidate.validatedListenerPid !== undefined && + candidate.validatedListenerPid !== args.currentListenerPid + ) { + args.cache.delete(key) + args.validationBaselines.delete(key) + args.startupAbsentAllowances.delete(key) + args.onEvict(worktreeId) + continue + } + if (!best || shouldReplace(best.entry, candidate)) { + best = { worktreeId, entry: candidate } + } + } + if ( + best && + args.currentListenerPid !== undefined && + best.entry.validatedListenerPid === undefined + ) { + best.entry.validatedListenerPid = args.currentListenerPid + args.validationBaselines.delete(cacheKey(best.worktreeId, args.port)) + args.startupAbsentAllowances.delete(cacheKey(best.worktreeId, args.port)) + } + return best?.entry +} diff --git a/src/main/ports/advertised-url-watcher.test.ts b/src/main/ports/advertised-url-watcher.test.ts index 17fcc5803bb..5fe46ef181a 100644 --- a/src/main/ports/advertised-url-watcher.test.ts +++ b/src/main/ports/advertised-url-watcher.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { - AdvertisedUrlWatcher, - classifyHost, - extractUrlCandidates, - stripTerminalControls -} from './advertised-url-watcher' +import { AdvertisedUrlWatcher } from './advertised-url-watcher' +import { classifyHost, extractUrlCandidates, stripTerminalControls } from './advertised-url-parsing' const WORKTREE = 'repo::/repo' const PTY = 'pty-1' diff --git a/src/main/ports/advertised-url-watcher.ts b/src/main/ports/advertised-url-watcher.ts index 3f8ca60bcce..ebbbecdf25f 100644 --- a/src/main/ports/advertised-url-watcher.ts +++ b/src/main/ports/advertised-url-watcher.ts @@ -1,28 +1,25 @@ -/* eslint-disable no-control-regex, max-lines -- control-sequence regexes need raw matching; URL parsing, host classification, cache lifecycle, and cross-worktree lookup stay in one file to keep the rules in lockstep. */ // Watches PTY output for HTTP(S) URLs dev servers print on startup, caching the // advertised origin per {worktreeId, port} for the ports panel (vs the kernel bind). // Why a separate stateful buffer per PTY: ANSI sequences and URLs can straddle PTY // write boundaries, so we accumulate raw bytes and strip-and-scan only at newlines. - -const PER_PTY_BUFFER_LIMIT = 4096 -const PENDING_PRE_BIND_LIMIT = 16 * 1024 -/** Cap on distinct never-bound PTY IDs; spawn-failure paths never bindPty, so without a bound they'd leak one entry each. */ -const MAX_PENDING_ENTRIES = 32 -const MAX_CACHE_ENTRIES = 256 -const URL_CANDIDATE_LIMIT = 2048 - -// ANSI/OSC strippers mirror the runtime normalizer in src/main/runtime/orca-runtime.ts, plus URL-specific cursor-move handling to avoid fusing skipped text. -const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g -// Why: cursor moves in differential redraws skip on-screen cells; a URL-invalid guard drops the damaged candidate. -const CURSOR_MOVE_PATTERN = /\x1b\[[0-?]*[ -/]*[CDGHf]/g -const CURSOR_MOVE_URL_GUARD = '[' -const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g -const SINGLE_ESC_PATTERN = /\x1b[@-_]/g -const CONTROL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g - -// Permissive matcher (real validation is `new URL()` below); stops at non-URL chars so terminal punctuation isn't absorbed. -const URL_CANDIDATE_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/gi - +import { + MAX_CACHE_ENTRIES, + MAX_PENDING_ENTRIES, + PENDING_PRE_BIND_LIMIT, + PtyBuffer, + cacheKey, + dedupeChangeEvents, + extractUrlCandidates, + observedListenersByPort, + worktreeIdFromCacheKey, + type CacheKey, + type ListenerScanState +} from './advertised-url-parsing' +import { considerAdvertisedUrl } from './advertised-url-cache-update' +import { + lookupBestAdvertisedUrl, + shouldEvictAdvertisedUrlAfterScan +} from './advertised-url-reconciliation' export type HostKind = 'custom' | 'loopback' | 'private-ip' | 'public-ip' export type AdvertisedUrl = { @@ -33,8 +30,6 @@ export type AdvertisedUrl = { port: number ptyId: string lastSeenAt: number - /** Listener PID this URL was validated against on a prior scan; a later mismatch evicts the entry. - * Captured on first scan (not at capture time) because the PTY shell PID isn't the listener PID. */ validatedListenerPid?: number } @@ -48,209 +43,8 @@ export type AdvertisedUrlListenerObservation = { pid?: number } -type CacheKey = string -type ListenerScanState = { kind: 'absent' } | { kind: 'present'; pid?: number } - -function cacheKey(worktreeId: string, port: number): CacheKey { - return `${worktreeId}::${port}` -} - -function worktreeIdFromCacheKey(key: CacheKey, port: number): string { - const suffix = `::${port}` - return key.endsWith(suffix) ? key.slice(0, -suffix.length) : key -} - -class PtyBuffer { - private raw = '' - - /** Append a chunk; return cleaned text up to the last newline. The tail stays buffered so a URL or ANSI sequence split across chunks survives. */ - ingest(chunk: string): string { - const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r') - // Keep the suffix directly so oversized chunks never materialize a throwaway full concatenation. - if (chunk.length >= PER_PTY_BUFFER_LIMIT) { - this.raw = chunk.slice(-PER_PTY_BUFFER_LIMIT) - } else if (this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT) { - this.raw = `${this.raw.slice(-(PER_PTY_BUFFER_LIMIT - chunk.length))}${chunk}` - } else { - this.raw += chunk - } - if (!chunkHasLineBreak) { - return '' - } - const lastNewline = lastLineBreak(this.raw) - if (lastNewline === -1) { - return '' - } - const finalized = this.raw.slice(0, lastNewline + 1) - this.raw = this.raw.slice(lastNewline + 1) - return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : '' - } -} - -function mayContainHttpUrl(text: string): boolean { - // Control stripping cannot create any character required by an HTTP scheme. - return ( - (text.includes('h') || text.includes('H')) && - (text.includes('t') || text.includes('T')) && - (text.includes('p') || text.includes('P')) && - text.includes(':') && - text.includes('/') - ) -} - -function lastLineBreak(text: string): number { - // Accept either \n or \r as a finalize point (\r\n is normalized later in stripTerminalControls). - for (let i = text.length - 1; i >= 0; i--) { - const ch = text.charCodeAt(i) - if (ch === 0x0a || ch === 0x0d) { - return i - } - } - return -1 -} - -export function stripTerminalControls(text: string): string { - return text - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(OSC_PATTERN, '') - .replace(CURSOR_MOVE_PATTERN, CURSOR_MOVE_URL_GUARD) - .replace(CSI_PATTERN, '') - .replace(SINGLE_ESC_PATTERN, '') - .replace(CONTROL_PATTERN, '') -} - -export function extractUrlCandidates(cleaned: string): URL[] { - const results: URL[] = [] - for (const match of cleaned.matchAll(URL_CANDIDATE_PATTERN)) { - let candidate = match[0] - if (candidate.length > URL_CANDIDATE_LIMIT) { - continue - } - // Strip common trailing punctuation that cannot end a real URL. - while (candidate.length > 0 && /[.,;:!?)\]}>'"`]/.test(candidate.slice(-1))) { - candidate = candidate.slice(0, -1) - } - const url = parseUrl(candidate) - if (url) { - results.push(url) - } - } - return results -} - -function parseUrl(candidate: string): URL | null { - try { - const url = new URL(candidate) - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return null - } - if (!url.hostname) { - return null - } - return url - } catch { - return null - } -} - -export function classifyHost(hostname: string): HostKind { - // Why: strip IPv6 brackets so this public API accepts both "[::1]" (Node's form) and bare literals. - const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '') - if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { - return 'loopback' - } - if (isIpv4(lower)) { - if (isPrivateIpv4(lower)) { - return 'private-ip' - } - return 'public-ip' - } - if (isIpv6(lower)) { - if (isPrivateIpv6(lower)) { - return 'private-ip' - } - return 'public-ip' - } - // Anything else is a DNS name — that's what we prefer for dev servers. - return 'custom' -} - -function isIpv4(value: string): boolean { - const parts = value.split('.') - if (parts.length !== 4) { - return false - } - return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255) -} - -function isPrivateIpv4(value: string): boolean { - const [a, b] = value.split('.').map((n) => Number(n)) - // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local) - if (a === 10) { - return true - } - if (a === 172 && b >= 16 && b <= 31) { - return true - } - if (a === 192 && b === 168) { - return true - } - if (a === 169 && b === 254) { - return true - } - return false -} - -function isUnspecifiedHost(hostname: string): boolean { - const stripped = hostname.toLowerCase().replace(/^\[|\]$/g, '') - return stripped === '0.0.0.0' || stripped === '::' || stripped === '*' -} - -function isIpv6(value: string): boolean { - // url.hostname for IPv6 returns lowercase without brackets — quick sniff. - return value.includes(':') && /^[0-9a-f:]+$/.test(value) -} - -function isPrivateIpv6(value: string): boolean { - // fc00::/7 (ULA) and fe80::/10 (link-local) - if (value.startsWith('fc') || value.startsWith('fd')) { - return true - } - const firstHextet = Number.parseInt(value.split(':', 1)[0], 16) - return Number.isFinite(firstHextet) && (firstHextet & 0xffc0) === 0xfe80 -} - -function hostKindScore(kind: HostKind): number { - // Prefer custom DNS > loopback > private IP > public IP: loopback beats LAN for cert/cookie reasons on one machine. - switch (kind) { - case 'custom': - return 3 - case 'loopback': - return 2 - case 'private-ip': - return 1 - case 'public-ip': - return 0 - } -} - -function shouldReplace(existing: AdvertisedUrl, candidate: AdvertisedUrl): boolean { - const oldScore = hostKindScore(existing.hostKind) - const newScore = hostKindScore(candidate.hostKind) - if (newScore !== oldScore) { - return newScore > oldScore - } - if (existing.protocol !== candidate.protocol) { - return candidate.protocol === 'https' - } - return candidate.lastSeenAt >= existing.lastSeenAt -} - export type AdvertisedUrlWatcherOptions = { - /** Override the clock; useful for tests. */ now?: () => number - /** Override the max cache entries (default 256). */ maxCacheEntries?: number } @@ -370,63 +164,23 @@ export class AdvertisedUrlWatcher { } const timestamp = now ?? this.now() for (const url of extractUrlCandidates(finalized)) { - this.consider(url, ptyId, worktreeId, timestamp) - } - } - - private consider(url: URL, ptyId: string, worktreeId: string, timestamp: number): void { - const protocol = url.protocol === 'https:' ? 'https' : 'http' - const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : 80 - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return - } - const hostname = url.hostname - // Why: wildcard bind hosts (0.0.0.0, ::) can't be opened in a browser; keep the scanner's localhost default instead. - if (isUnspecifiedHost(hostname)) { - return - } - const hostKind = classifyHost(hostname) - // Why: store origin only (no path/query/fragment/userinfo) so an OAuth callback or token can't leak to the panel. - const origin = `${protocol}://${formatHostForOrigin(url)}${ - isDefaultPort(protocol, port) ? '' : `:${port}` - }` - const candidate: AdvertisedUrl = { - origin, - host: hostname, - hostKind, - protocol, - port, - ptyId, - lastSeenAt: timestamp - } - const key = cacheKey(worktreeId, port) - const existing = this.cache.get(key) - if (!existing || shouldReplace(existing, candidate)) { - this.cache.set(key, candidate) - const baseline = this.currentScanStateFor(worktreeId, port) - if (baseline) { - this.validationBaselines.set(key, baseline) - if (baseline.kind === 'absent') { - // Why: the URL can arrive between the banner print and the scanner seeing the listener; allow one settling scan. - this.startupAbsentAllowances.add(key) - } else { - this.startupAbsentAllowances.delete(key) - } - } else { - this.validationBaselines.delete(key) - // Why: PTY output can arrive before any scan snapshot exists; grant the same one-scan absent allowance. - this.startupAbsentAllowances.add(key) - } - const changedEvents = this.enforceCacheLimit() - if (!existing || existing.origin !== candidate.origin) { - changedEvents.push({ worktreeId, port }) - } - for (const event of dedupeChangeEvents(changedEvents)) { + const events = considerAdvertisedUrl({ + url, + ptyId, + worktreeId, + timestamp, + cache: this.cache, + validationBaselines: this.validationBaselines, + startupAbsentAllowances: this.startupAbsentAllowances, + currentScanState: this.currentScanStateFor( + worktreeId, + url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80 + ), + maxCacheEntries: this.maxCacheEntries + }) + for (const event of events) { this.emitChange(event) } - } else { - // Refresh recency on the existing entry so it isn't evicted by LRU. - existing.lastSeenAt = timestamp } } @@ -440,26 +194,6 @@ export class AdvertisedUrlWatcher { } } - private enforceCacheLimit(): AdvertisedUrlChangeEvent[] { - if (this.cache.size <= this.maxCacheEntries) { - return [] - } - // Drop oldest by lastSeenAt until we are back at the cap. - const entries = Array.from(this.cache.entries()).sort( - (a, b) => a[1].lastSeenAt - b[1].lastSeenAt - ) - const overflow = this.cache.size - this.maxCacheEntries - const removedEvents: AdvertisedUrlChangeEvent[] = [] - for (let i = 0; i < overflow; i++) { - const [key, entry] = entries[i] - this.cache.delete(key) - this.validationBaselines.delete(key) - this.startupAbsentAllowances.delete(key) - removedEvents.push({ worktreeId: worktreeIdFromCacheKey(key, entry.port), port: entry.port }) - } - return removedEvents - } - lookup(worktreeId: string, port: number, currentListenerPid?: number): AdvertisedUrl | undefined { const key = cacheKey(worktreeId, port) const entry = this.cache.get(key) @@ -510,7 +244,15 @@ export class AdvertisedUrlWatcher { ? ({ kind: 'present', pid: observedByPort.get(entry.port) } as const) : ({ kind: 'absent' } as const) - if (this.shouldEvictAfterScan(key, entry, current)) { + if ( + shouldEvictAdvertisedUrlAfterScan({ + key, + entry, + current, + validationBaselines: this.validationBaselines, + startupAbsentAllowances: this.startupAbsentAllowances + }) + ) { this.cache.delete(key) this.validationBaselines.delete(key) this.startupAbsentAllowances.delete(key) @@ -528,41 +270,6 @@ export class AdvertisedUrlWatcher { } } - private shouldEvictAfterScan( - key: CacheKey, - entry: AdvertisedUrl, - current: ListenerScanState - ): boolean { - const baseline = this.validationBaselines.get(key) - if (current.kind === 'absent') { - if ( - entry.validatedListenerPid === undefined && - baseline?.kind !== 'present' && - this.startupAbsentAllowances.delete(key) - ) { - return false - } - return true - } - if ( - entry.validatedListenerPid !== undefined && - current.pid !== undefined && - entry.validatedListenerPid !== current.pid - ) { - return true - } - if (baseline?.kind === 'absent' && current.kind === 'present') { - this.startupAbsentAllowances.delete(key) - // Why: dev servers print their URL before the listener scan sees the port; let this first present scan validate it. - return false - } - return ( - entry.validatedListenerPid === undefined && - baseline !== undefined && - scanStateChanged(baseline, current) - ) - } - /** Find the best advertised URL for `port` across worktrees, scored via `shouldReplace`. * Scans all worktrees on the connection because an SSH port scanner reports ports for the * whole connection, not per-worktree. With `currentListenerPid`, mismatched pinned entries @@ -572,35 +279,15 @@ export class AdvertisedUrlWatcher { port: number, currentListenerPid?: number ): AdvertisedUrl | undefined { - let best: { worktreeId: string; entry: AdvertisedUrl } | undefined - for (const worktreeId of worktreeIds) { - const key = cacheKey(worktreeId, port) - const candidate = this.cache.get(key) - if (!candidate) { - continue - } - if (currentListenerPid !== undefined) { - if ( - candidate.validatedListenerPid !== undefined && - candidate.validatedListenerPid !== currentListenerPid - ) { - this.cache.delete(key) - this.validationBaselines.delete(key) - this.startupAbsentAllowances.delete(key) - this.emitChange({ worktreeId, port }) - continue - } - } - if (!best || shouldReplace(best.entry, candidate)) { - best = { worktreeId, entry: candidate } - } - } - if (best && currentListenerPid !== undefined && best.entry.validatedListenerPid === undefined) { - best.entry.validatedListenerPid = currentListenerPid - this.validationBaselines.delete(cacheKey(best.worktreeId, port)) - this.startupAbsentAllowances.delete(cacheKey(best.worktreeId, port)) - } - return best?.entry + return lookupBestAdvertisedUrl({ + worktreeIds, + port, + currentListenerPid, + cache: this.cache, + validationBaselines: this.validationBaselines, + startupAbsentAllowances: this.startupAbsentAllowances, + onEvict: (worktreeId) => this.emitChange({ worktreeId, port }) + }) } clear(): void { @@ -622,63 +309,5 @@ export class AdvertisedUrlWatcher { } } -function isDefaultPort(protocol: 'http' | 'https', port: number): boolean { - return (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) -} - -/** Process-wide singleton fed by the runtime and read by scanner enrichment. Tests should instantiate their own instead. */ +/** Process-wide singleton fed by the runtime and read by scanner enrichment. */ export const advertisedUrlWatcher = new AdvertisedUrlWatcher() - -function formatHostForOrigin(url: URL): string { - // Why: some JS runtimes strip the IPv6 brackets Node adds; re-bracket a bare IPv6 literal. - const h = url.hostname - if (h.startsWith('[') && h.endsWith(']')) { - return h - } - if (h.includes(':')) { - return `[${h}]` - } - return h -} - -function observedListenersByPort( - observations: readonly AdvertisedUrlListenerObservation[] -): Map { - const observed = new Map() - for (const observation of observations) { - const existing = observed.get(observation.port) - if (!observed.has(observation.port)) { - observed.set(observation.port, observation.pid) - } else if (existing !== observation.pid) { - // Multiple host-specific listeners on one port make PID attribution ambiguous; keep presence only. - observed.set(observation.port, undefined) - } - } - return observed -} - -function scanStateChanged(previous: ListenerScanState, current: ListenerScanState): boolean { - if (previous.kind !== current.kind) { - return true - } - if (previous.kind === 'absent' || current.kind === 'absent') { - return false - } - return previous.pid !== undefined && current.pid !== undefined && previous.pid !== current.pid -} - -function dedupeChangeEvents( - events: readonly AdvertisedUrlChangeEvent[] -): AdvertisedUrlChangeEvent[] { - const seen = new Set() - const deduped: AdvertisedUrlChangeEvent[] = [] - for (const event of events) { - const key = cacheKey(event.worktreeId, event.port) - if (seen.has(key)) { - continue - } - seen.add(key) - deduped.push(event) - } - return deduped -} diff --git a/src/main/ports/local-workspace-platform-port-scanner.ts b/src/main/ports/local-workspace-platform-port-scanner.ts new file mode 100644 index 00000000000..7e3b9941617 --- /dev/null +++ b/src/main/ports/local-workspace-platform-port-scanner.ts @@ -0,0 +1,322 @@ +import { readFile, readdir, readlink } from 'node:fs/promises' +import { getProcessOutputFields } from '../../shared/process-output-field-scanner' +import { readWindowsProcessTable } from '../windows/windows-process-table' +import { runPortScanCommand } from './port-scan-command-client' +import { + recallListenerMetadata, + rememberListenerMetadata, + shouldSkipMetadataCommands, + type PlatformListeningPortScan, + type ProcessMetadata, + type RawListeningPort, + type WorkspacePortScanOptions +} from './local-workspace-port-scan-state' +import { + dedupeRawPorts, + parseAddressWithPort, + parseProcAddress +} from './local-workspace-port-address' + +export function parseLsofListeningOutput(output: string): RawListeningPort[] { + const ports: RawListeningPort[] = [] + let currentPid: number | undefined + let currentProcessName: string | undefined + + for (const line of output.split('\n')) { + if (!line) { + continue + } + const tag = line[0] + const value = line.slice(1) + if (tag === 'p') { + const pid = Number.parseInt(value, 10) + currentPid = Number.isFinite(pid) ? pid : undefined + currentProcessName = undefined + } else if (tag === 'c') { + currentProcessName = value + } else if (tag === 'n') { + const parsed = parseAddressWithPort(value) + if (parsed) { + ports.push({ pid: currentPid, processName: currentProcessName, ...parsed }) + } + } + } + + return dedupeRawPorts(ports) +} + +export function parseNetstatListeningOutput(output: string): RawListeningPort[] { + const ports: RawListeningPort[] = [] + for (const line of output.split('\n')) { + const fields = getProcessOutputFields(line, 6) + if (fields[0]?.toUpperCase() !== 'TCP') { + continue + } + const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING') + if (stateIndex < 2) { + continue + } + const parsed = parseAddressWithPort(fields[1]) + const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10) + if (!parsed) { + continue + } + ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined }) + } + return dedupeRawPorts(ports) +} + +export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] { + const results: { host: string; port: number; inode: number }[] = [] + const lines = content.split('\n') + for (let i = 1; i < lines.length; i++) { + const fields = getProcessOutputFields(lines[i], 10) + if (fields.length < 10 || fields[3] !== '0A') { + continue + } + const parsed = parseProcAddress(fields[1]) + const inode = Number.parseInt(fields[9], 10) + if (!parsed || !Number.isFinite(inode) || inode === 0) { + continue + } + results.push({ ...parsed, inode }) + } + return results +} + +export async function scanPlatformListeningPorts( + options: WorkspacePortScanOptions +): Promise { + const scan = await dispatchPlatformListeningPortScan(options) + if (scan.metadataAvailable) { + rememberListenerMetadata(scan.ports) + return scan + } + return { ...scan, ports: scan.ports.map(recallListenerMetadata) } +} + +async function dispatchPlatformListeningPortScan( + options: WorkspacePortScanOptions +): Promise { + if (process.platform === 'linux') { + return scanLinuxProcPorts() + } + if (process.platform === 'darwin') { + return scanDarwinLsofPorts(options) + } + if (process.platform === 'win32') { + return scanWindowsNetstatPorts(options) + } + throw new Error(`Port scanning is not supported on ${process.platform}`) +} + +async function scanDarwinLsofPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('lsof', [ + '-nP', + '-iTCP', + '-sTCP:LISTEN', + '-F', + 'pcn' + ]) + const ports = parseLsofListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } + const metadata = await loadDarwinProcessMetadata( + new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) + ) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } +} + +async function scanWindowsNetstatPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('netstat', ['-ano', '-p', 'tcp']) + const ports = parseNetstatListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } + const metadata = await loadWindowsProcessMetadata( + new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) + ) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } +} + +async function scanLinuxProcPorts(): Promise { + const [tcp4, tcp6] = await Promise.all([ + readProcNet('/proc/net/tcp'), + readProcNet('/proc/net/tcp6') + ]) + const sockets = [...tcp4, ...tcp6] + const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode))) + const metadata = new Map() + const rawPorts: RawListeningPort[] = [] + + for (const socket of sockets) { + const pid = inodeToPid.get(socket.inode) + if (pid != null && !metadata.has(pid)) { + metadata.set(pid, await loadLinuxProcessMetadata(pid)) + } + rawPorts.push({ + host: socket.host, + port: socket.port, + pid, + ...metadata.get(pid ?? -1) + }) + } + + return { ports: dedupeRawPorts(rawPorts), metadataAvailable: true } +} + +async function readProcNet( + filePath: string +): Promise<{ host: string; port: number; inode: number }[]> { + try { + return parseProcNetTcp(await readFile(filePath, 'utf-8')) + } catch { + return [] + } +} + +async function mapLinuxInodesToPids(inodes: Set): Promise> { + const result = new Map() + if (inodes.size === 0) { + return result + } + let pids: string[] + try { + pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) + } catch { + return result + } + + for (const pidText of pids) { + let fds: string[] + try { + fds = await readdir(`/proc/${pidText}/fd`) + } catch { + continue + } + const pid = Number.parseInt(pidText, 10) + for (const fd of fds) { + let link: string + try { + link = await readlink(`/proc/${pidText}/fd/${fd}`) + } catch { + continue + } + const match = link.match(/^socket:\[(\d+)\]$/) + if (!match) { + continue + } + const inode = Number.parseInt(match[1], 10) + if (inodes.has(inode)) { + result.set(inode, pid) + } + } + } + return result +} + +async function loadLinuxProcessMetadata(pid: number): Promise { + const [comm, cmdline, cwd] = await Promise.all([ + readTextIfAvailable(`/proc/${pid}/comm`), + readTextIfAvailable(`/proc/${pid}/cmdline`), + readlink(`/proc/${pid}/cwd`).catch(() => undefined) + ]) + return { + processName: comm?.trim() || undefined, + commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined, + cwd + } +} + +async function loadDarwinProcessMetadata(pids: Set): Promise> { + const result = new Map() + const pidList = Array.from(pids).join(',') + if (!pidList) { + return result + } + + // Why (#11161): sequential, not Promise.all — the probe worker dispatches one + // command at a time, so issuing both at once would only queue the second. + const cwdOutput = await runPortScanCommand('lsof', [ + '-a', + '-p', + pidList, + '-d', + 'cwd', + '-Fn' + ]).catch(() => null) + const commandOutput = await runPortScanCommand('ps', [ + '-p', + pidList, + '-o', + 'pid=', + '-o', + 'command=' + ]).catch(() => null) + + let currentPid: number | null = null + for (const line of cwdOutput?.stdout.split('\n') ?? []) { + if (line.startsWith('p')) { + const pid = Number.parseInt(line.slice(1), 10) + currentPid = Number.isFinite(pid) ? pid : null + } else if (line.startsWith('n') && currentPid != null) { + result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined }) + } + } + + for (const line of commandOutput?.stdout.split('\n') ?? []) { + const match = line.match(/^\s*(\d+)\s+(.+)$/) + if (!match) { + continue + } + const pid = Number.parseInt(match[1], 10) + result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined }) + } + + return result +} + +async function loadWindowsProcessMetadata( + pids: Set +): Promise> { + const result = new Map() + if (pids.size === 0) { + return result + } + try { + // Why the native snapshot: attributing ports used to fork a powershell.exe + // per scan just to turn PIDs into names. That is a ~700ms cold start, a + // conhost window, and one more thing a Group Policy can block -- for data + // the panel treats as optional anyway. + for (const row of await readWindowsProcessTable()) { + if (pids.has(row.pid)) { + result.set(row.pid, { + processName: row.name, + commandLine: row.command || undefined + }) + } + } + } catch { + // Process metadata is optional; port rows still render without attribution. + } + return result +} + +async function readTextIfAvailable(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return undefined + } +} diff --git a/src/main/ports/local-workspace-port-address.ts b/src/main/ports/local-workspace-port-address.ts new file mode 100644 index 00000000000..77b6e0b0b19 --- /dev/null +++ b/src/main/ports/local-workspace-port-address.ts @@ -0,0 +1,71 @@ +import type { RawListeningPort } from './local-workspace-port-scan-state' + +export function connectHostForBindHost(host: string): string { + if (host === '*' || host === '0.0.0.0' || host === '::') { + return 'localhost' + } + return host +} + +export function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] { + const seen = new Set() + const result: RawListeningPort[] = [] + for (const port of ports) { + const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}` + if (seen.has(key)) { + continue + } + seen.add(key) + result.push(port) + } + return result +} + +export function parseAddressWithPort(value: string): { host: string; port: number } | null { + const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '') + const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/) + if (bracketed) { + return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) } + } + const match = trimmed.match(/^(.+):(\d+)$/) + if (!match) { + return null + } + const port = Number.parseInt(match[2], 10) + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return null + } + return { host: match[1], port } +} + +export function parseProcAddress(hexAddress: string): { host: string; port: number } | null { + const [addrHex, portHex] = hexAddress.split(':') + const port = Number.parseInt(portHex, 16) + if (!Number.isFinite(port) || port === 0) { + return null + } + if (addrHex.length === 8) { + const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16)) + return { host: bytes.join('.'), port } + } + if (addrHex.length === 32) { + if (addrHex === '00000000000000000000000000000000') { + return { host: '::', port } + } + if (addrHex === '00000000000000000000000001000000') { + return { host: '::1', port } + } + return { host: formatIPv6Address(addrHex), port } + } + return null +} + +function formatIPv6Address(hex: string): string { + const groups: string[] = [] + for (let i = 0; i < 32; i += 8) { + const chunk = hex.slice(i, i + 8) + const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2) + groups.push(reversed.slice(0, 4), reversed.slice(4, 8)) + } + return groups.map((group) => group.replace(/^0+/, '') || '0').join(':') +} diff --git a/src/main/ports/local-workspace-port-attribution.ts b/src/main/ports/local-workspace-port-attribution.ts new file mode 100644 index 00000000000..21180a512e1 --- /dev/null +++ b/src/main/ports/local-workspace-port-attribution.ts @@ -0,0 +1,210 @@ +import path from 'node:path' +import type { + WorkspacePort, + WorkspacePortOwner, + WorkspacePortProbe +} from '../../shared/workspace-ports' +import type { AdvertisedUrlWatcher } from './advertised-url-watcher' +import { connectHostForBindHost } from './local-workspace-port-address' +import type { + NormalizedWorkspacePortProbe, + RawListeningPort +} from './local-workspace-port-scan-state' + +const HTTP_PORTS: Record = { + 80: true, + 3000: true, + 3001: true, + 4200: true, + 5000: true, + 5173: true, + 5174: true, + 8000: true, + 8080: true, + 8888: true +} +const HTTPS_PORTS: Record = { 443: true, 8443: true } + +export function attributePortToWorkspace( + port: Pick, + worktrees: WorkspacePortProbe[] +): WorkspacePortOwner | undefined { + return attributePortToNormalizedWorkspaces(port, normalizeWorkspacePortProbes(worktrees)) +} + +export function normalizeWorkspacePortProbes( + worktrees: readonly WorkspacePortProbe[] +): NormalizedWorkspacePortProbe[] { + return worktrees.map((worktree) => ({ + worktree, + normalizedPath: normalizeComparablePath(worktree.path) + })) +} + +function attributePortToNormalizedWorkspaces( + port: Pick, + worktrees: readonly NormalizedWorkspacePortProbe[] +): WorkspacePortOwner | undefined { + const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null + const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null + + const cwdMatch = cwd + ? pickDeepestMatching(worktrees, ({ normalizedPath }) => + isSameOrDescendant(cwd, normalizedPath) + ) + : undefined + if (cwdMatch) { + return toOwner(cwdMatch.worktree, 'cwd') + } + + if (!commandLine) { + return undefined + } + + const commandMatch = pickDeepestMatching(worktrees, ({ normalizedPath }) => + includesPathBoundary(commandLine, normalizedPath) + ) + return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined +} +export function enrichPort( + port: RawListeningPort, + worktrees: readonly NormalizedWorkspacePortProbe[], + urlWatcher: Pick +): WorkspacePort { + const owner = attributePortToNormalizedWorkspaces(port, worktrees) + const base = { + id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, + bindHost: port.host, + connectHost: connectHostForBindHost(port.host), + port: port.port, + pid: port.pid, + processName: port.processName, + protocol: inferProtocol(port.port) + } + + if (owner) { + // Why: only enrich workspace-attributed ports. Container and external + // ports may have URLs printed in unrelated terminals — the worktree + // scoping is the primary false-positive filter. + const advertised = urlWatcher.lookup(owner.worktreeId, port.port, port.pid) + return { + ...base, + protocol: advertised?.protocol ?? base.protocol, + kind: 'workspace', + owner, + ...(advertised ? { advertisedUrl: advertised.origin } : {}) + } + } + if (isContainerProcess(port)) { + return { ...base, kind: 'container' } + } + return { ...base, kind: 'external' } +} + +export function reconcileAdvertisedUrls( + ports: RawListeningPort[], + worktrees: readonly NormalizedWorkspacePortProbe[], + urlWatcher: Pick +): void { + const observationsByWorktree = new Map() + for (const worktree of worktrees) { + observationsByWorktree.set(worktree.worktree.id, []) + } + for (const port of ports) { + const owner = attributePortToNormalizedWorkspaces(port, worktrees) + if (!owner) { + continue + } + observationsByWorktree.get(owner.worktreeId)?.push({ port: port.port, pid: port.pid }) + } + for (const [worktreeId, observations] of observationsByWorktree) { + // Why: the scanner sees port disappearance and PID changes before a lazy + // lookup would otherwise pin a stale banner to a new listener. + urlWatcher.reconcileScan([worktreeId], observations) + } +} + +export function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number { + const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2 + const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2 + return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost) +} + +function inferProtocol(port: number): 'http' | 'https' | 'unknown' { + if (HTTPS_PORTS[port] === true) { + return 'https' + } + if (HTTP_PORTS[port] === true) { + return 'http' + } + return 'unknown' +} + +export function isContainerProcess( + port: Pick +): boolean { + const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase() + return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack) +} + +function toOwner( + worktree: WorkspacePortProbe, + confidence: WorkspacePortOwner['confidence'] +): WorkspacePortOwner { + return { + worktreeId: worktree.id, + repoId: worktree.repoId, + displayName: worktree.displayName, + path: worktree.path, + confidence + } +} + +function pickDeepestMatching( + candidates: readonly T[], + predicate: (candidate: T) => boolean +): T | undefined { + let best: T | undefined + for (const candidate of candidates) { + if (!predicate(candidate)) { + continue + } + if (!best || candidate.normalizedPath.length > best.normalizedPath.length) { + best = candidate + } + } + return best +} + +function isSameOrDescendant(candidate: string, parent: string): boolean { + return candidate === parent || candidate.startsWith(`${parent}/`) +} + +function includesPathBoundary(commandLine: string, normalizedPath: string): boolean { + let index = commandLine.indexOf(normalizedPath) + while (index !== -1) { + const before = index === 0 ? '' : commandLine[index - 1] + const after = commandLine[index + normalizedPath.length] ?? '' + const startsOnBoundary = before === '' || /\s|["'=]/.test(before) + const endsOnBoundary = after === '' || /[\s"'/:]/.test(after) + if (startsOnBoundary && endsOnBoundary) { + return true + } + index = commandLine.indexOf(normalizedPath, index + normalizedPath.length) + } + return false +} + +function normalizeComparablePath(input: string): string { + if (input.startsWith('/')) { + // Why: command-line evidence for SSH/WSL/POSIX workspaces can be evaluated + // on a Windows host; path.resolve would reinterpret "/repo" as "G:/repo". + return normalizeComparableText(path.posix.resolve(input)) + } + return normalizeComparableText(path.resolve(input)) +} + +function normalizeComparableText(input: string): string { + const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} diff --git a/src/main/ports/local-workspace-port-scan-state.ts b/src/main/ports/local-workspace-port-scan-state.ts new file mode 100644 index 00000000000..582e8b9731e --- /dev/null +++ b/src/main/ports/local-workspace-port-scan-state.ts @@ -0,0 +1,122 @@ +import type { WorkspacePortScanResult, WorkspacePortProbe } from '../../shared/workspace-ports' +import { isPortScanWorkerUnavailableError } from './port-scan-command-client' +import { PortScanCommandTimeoutError } from './port-scan-command-protocol' +import { + WorkspacePortScanTimeoutBackoff, + type WorkspacePortScanTimeoutBackoffSnapshot +} from './workspace-port-scan-timeout-backoff' + +const SLOW_SPAWN_SKIP_METADATA_MS = 2_000 +const commandTimeoutBackoff = new WorkspacePortScanTimeoutBackoff() +let loggedWorkerUnavailable = false +let skippedMetadataOnLastScan = false +let lastListenerMetadata = new Map() + +export type WorkspacePortScanOptions = { + requireMetadata?: boolean +} + +export type RawListeningPort = { + host: string + port: number + pid?: number + processName?: string + commandLine?: string + cwd?: string +} + +export type ProcessMetadata = { + processName?: string + commandLine?: string + cwd?: string +} + +export type NormalizedWorkspacePortProbe = { + worktree: WorkspacePortProbe + normalizedPath: string +} + +export type PlatformListeningPortScan = { + ports: RawListeningPort[] + metadataAvailable: boolean +} + +export function getWorkspacePortScanCooldown(): WorkspacePortScanTimeoutBackoffSnapshot { + return commandTimeoutBackoff.snapshot() +} + +export function recordWorkspacePortScanSuccess(): void { + commandTimeoutBackoff.recordSuccess() +} + +export function recordWorkspacePortScanTimeout(): void { + commandTimeoutBackoff.recordTimeout() +} + +export function resetWorkspacePortScanTimeoutBackoffForTests(): void { + commandTimeoutBackoff.reset() + loggedWorkerUnavailable = false + skippedMetadataOnLastScan = false + lastListenerMetadata = new Map() +} + +export function shouldSkipMetadataCommands( + spawnMs: number, + options: WorkspacePortScanOptions +): boolean { + if (options.requireMetadata) { + return false + } + const skip = spawnMs > SLOW_SPAWN_SKIP_METADATA_MS && !skippedMetadataOnLastScan + skippedMetadataOnLastScan = skip + return skip +} + +function listenerMetadataKey(port: RawListeningPort): string { + return `${port.pid ?? 'unknown'}:${port.host}:${port.port}` +} + +export function rememberListenerMetadata(ports: readonly RawListeningPort[]): void { + lastListenerMetadata = new Map( + ports.map((port) => [ + listenerMetadataKey(port), + { processName: port.processName, commandLine: port.commandLine, cwd: port.cwd } + ]) + ) +} + +export function recallListenerMetadata(port: RawListeningPort): RawListeningPort { + const remembered = lastListenerMetadata.get(listenerMetadataKey(port)) + if (!remembered) { + return port + } + return { + ...port, + processName: port.processName ?? remembered.processName, + commandLine: port.commandLine ?? remembered.commandLine, + cwd: port.cwd ?? remembered.cwd + } +} + +export function warnWorkspacePortScanFailure(error: unknown): void { + if (isPortScanWorkerUnavailableError(error)) { + if (loggedWorkerUnavailable) { + return + } + loggedWorkerUnavailable = true + } + console.warn('[workspace-ports] scan failed', error) +} + +export function isWorkspacePortScanCommandTimeout(error: unknown): boolean { + return error instanceof PortScanCommandTimeoutError +} + +export function makeUnavailableWorkspacePortScan(reason: string): WorkspacePortScanResult { + return { + platform: process.platform, + scannedAt: Date.now(), + ports: [], + unavailableReason: reason + } +} diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index 132eba1950e..be37ad60eb3 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,14 +1,13 @@ import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import path from 'node:path' +import { scanWorkspacePorts } from './local-workspace-port-scanner' +import { attributePortToWorkspace, isContainerProcess } from './local-workspace-port-attribution' import { - attributePortToWorkspace, - isContainerProcess, parseLsofListeningOutput, parseNetstatListeningOutput, - parseProcNetTcp, - resetWorkspacePortScanTimeoutBackoffForTests, - scanWorkspacePorts -} from './local-workspace-port-scanner' + parseProcNetTcp +} from './local-workspace-platform-port-scanner' +import { resetWorkspacePortScanTimeoutBackoffForTests } from './local-workspace-port-scan-state' import { PortScanCommandTimeoutError } from './port-scan-command-protocol' const runPortScanCommandMock = vi.hoisted(() => vi.fn()) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 66373db6929..7af3e8bba3c 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,81 +1,31 @@ -/* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, -attribution, and normalization rules that must stay in lockstep. */ -import { readFile, readdir, readlink } from 'node:fs/promises' -import path from 'node:path' -import type { - WorkspacePort, - WorkspacePortOwner, - WorkspacePortProbe, - WorkspacePortScanResult -} from '../../shared/workspace-ports' -import { getProcessOutputFields } from '../../shared/process-output-field-scanner' -import { readWindowsProcessTable } from '../windows/windows-process-table' +import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' -import { isPortScanWorkerUnavailableError, runPortScanCommand } from './port-scan-command-client' -import { PortScanCommandTimeoutError } from './port-scan-command-protocol' -import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' - -// Why (#11161): on an EDR-hooked host process creation alone can take seconds. -// Past this, skip the scan's optional metadata commands for one cycle so a scan -// costs roughly one stall instead of three. -const SLOW_SPAWN_SKIP_METADATA_MS = 2_000 +import { + compareWorkspacePorts, + enrichPort, + normalizeWorkspacePortProbes, + reconcileAdvertisedUrls +} from './local-workspace-port-attribution' +import { scanPlatformListeningPorts } from './local-workspace-platform-port-scanner' +import { + getWorkspacePortScanCooldown, + isWorkspacePortScanCommandTimeout, + makeUnavailableWorkspacePortScan, + recordWorkspacePortScanSuccess, + recordWorkspacePortScanTimeout, + warnWorkspacePortScanFailure, + type WorkspacePortScanOptions +} from './local-workspace-port-scan-state' const MAX_PORTS = 200 -const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888]) -const HTTPS_PORTS = new Set([443, 8443]) - -const commandTimeoutBackoff = new WorkspacePortScanTimeoutBackoff() -let loggedWorkerUnavailable = false -// Why (#11161): a hooked host stalls every spawn, so gating only on the current -// scan would drop metadata forever. Never skip twice running, so attribution — -// and the Stop action and advertised-URL matching that ride on it — recovers on -// the next tick. -let skippedMetadataOnLastScan = false -// Why (#11161): a skipped cycle would otherwise report every listener as -// external, so the panel flip-flops and the Stop action loses its owner. Carry -// the previous cycle's metadata forward, keyed tightly enough that a recycled -// pid cannot inherit it. -let lastListenerMetadata = new Map() - -export type WorkspacePortScanOptions = { - /** Set by attribution-dependent callers (Stop, the localhost-label allowlist) - * that must never trade owner metadata for scan latency. */ - requireMetadata?: boolean -} - -type RawListeningPort = { - host: string - port: number - pid?: number - processName?: string - commandLine?: string - cwd?: string -} - -type ProcessMetadata = { - processName?: string - commandLine?: string - cwd?: string -} - -type NormalizedWorkspacePortProbe = { - worktree: WorkspacePortProbe - normalizedPath: string -} - -type PlatformListeningPortScan = { - ports: RawListeningPort[] - /** False when a stalled spawn made the scan skip its cwd/command-line probes. */ - metadataAvailable: boolean -} export async function scanWorkspacePorts( worktrees: WorkspacePortProbe[], urlWatcher: Pick = advertisedUrlWatcher, options: WorkspacePortScanOptions = {} ): Promise { - const cooldown = commandTimeoutBackoff.snapshot() + const cooldown = getWorkspacePortScanCooldown() if (cooldown.isCoolingDown) { - return makeUnavailableScan( + return makeUnavailableWorkspacePortScan( `Port scanning is temporarily paused after a command timeout. Retrying in ${Math.ceil( cooldown.remainingMs / 1000 )}s.` @@ -84,7 +34,7 @@ export async function scanWorkspacePorts( try { const { ports: rawPorts, metadataAvailable } = await scanPlatformListeningPorts(options) - commandTimeoutBackoff.recordSuccess() + recordWorkspacePortScanSuccess() const normalizedWorktrees = normalizeWorkspacePortProbes(worktrees) // Why (#11161): without cwd/command-line every port looks unattributed, and // reconciling that would read as "the listener vanished" and evict cached @@ -98,639 +48,10 @@ export async function scanWorkspacePorts( .slice(0, MAX_PORTS) return { platform: process.platform, scannedAt: Date.now(), ports } } catch (error) { - if (isCommandTimeoutError(error)) { - commandTimeoutBackoff.recordTimeout() + if (isWorkspacePortScanCommandTimeout(error)) { + recordWorkspacePortScanTimeout() } - warnScanFailure(error) - return makeUnavailableScan(`Port scanning is unavailable on ${process.platform}.`) + warnWorkspacePortScanFailure(error) + return makeUnavailableWorkspacePortScan(`Port scanning is unavailable on ${process.platform}.`) } } - -export function resetWorkspacePortScanTimeoutBackoffForTests(): void { - commandTimeoutBackoff.reset() - loggedWorkerUnavailable = false - skippedMetadataOnLastScan = false - lastListenerMetadata = new Map() -} - -function shouldSkipMetadataCommands(spawnMs: number, opts: WorkspacePortScanOptions): boolean { - if (opts.requireMetadata) { - // Leave the skip parity alone: it belongs to the background scan cadence, - // and a one-shot user action must not shift which tick degrades. - return false - } - const skip = spawnMs > SLOW_SPAWN_SKIP_METADATA_MS && !skippedMetadataOnLastScan - skippedMetadataOnLastScan = skip - return skip -} - -/** Identity tight enough that a recycled pid cannot inherit stale metadata. */ -function listenerMetadataKey(port: RawListeningPort): string { - return `${port.pid ?? 'unknown'}:${port.host}:${port.port}` -} - -function rememberListenerMetadata(ports: readonly RawListeningPort[]): void { - lastListenerMetadata = new Map( - ports.map((port) => [ - listenerMetadataKey(port), - { processName: port.processName, commandLine: port.commandLine, cwd: port.cwd } - ]) - ) -} - -function recallListenerMetadata(port: RawListeningPort): RawListeningPort { - const remembered = lastListenerMetadata.get(listenerMetadataKey(port)) - if (!remembered) { - return port - } - return { - ...port, - processName: port.processName ?? remembered.processName, - commandLine: port.commandLine ?? remembered.commandLine, - cwd: port.cwd ?? remembered.cwd - } -} - -// Why: a mispackaged probe worker fails identically forever, so logging it on -// every 30s scan tick is pure noise. -function warnScanFailure(error: unknown): void { - if (isPortScanWorkerUnavailableError(error)) { - if (loggedWorkerUnavailable) { - return - } - loggedWorkerUnavailable = true - } - console.warn('[workspace-ports] scan failed', error) -} - -function makeUnavailableScan(reason: string): WorkspacePortScanResult { - return { - platform: process.platform, - scannedAt: Date.now(), - ports: [], - unavailableReason: reason - } -} - -export function attributePortToWorkspace( - port: Pick, - worktrees: WorkspacePortProbe[] -): WorkspacePortOwner | undefined { - return attributePortToNormalizedWorkspaces(port, normalizeWorkspacePortProbes(worktrees)) -} - -function normalizeWorkspacePortProbes( - worktrees: readonly WorkspacePortProbe[] -): NormalizedWorkspacePortProbe[] { - return worktrees.map((worktree) => ({ - worktree, - normalizedPath: normalizeComparablePath(worktree.path) - })) -} - -function attributePortToNormalizedWorkspaces( - port: Pick, - worktrees: readonly NormalizedWorkspacePortProbe[] -): WorkspacePortOwner | undefined { - const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null - const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null - - const cwdMatch = cwd - ? pickDeepestMatching(worktrees, ({ normalizedPath }) => - isSameOrDescendant(cwd, normalizedPath) - ) - : undefined - if (cwdMatch) { - return toOwner(cwdMatch.worktree, 'cwd') - } - - if (!commandLine) { - return undefined - } - - const commandMatch = pickDeepestMatching(worktrees, ({ normalizedPath }) => - includesPathBoundary(commandLine, normalizedPath) - ) - return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined -} - -export function parseLsofListeningOutput(output: string): RawListeningPort[] { - const ports: RawListeningPort[] = [] - let currentPid: number | undefined - let currentProcessName: string | undefined - - for (const line of output.split('\n')) { - if (!line) { - continue - } - const tag = line[0] - const value = line.slice(1) - if (tag === 'p') { - const pid = Number.parseInt(value, 10) - currentPid = Number.isFinite(pid) ? pid : undefined - currentProcessName = undefined - } else if (tag === 'c') { - currentProcessName = value - } else if (tag === 'n') { - const parsed = parseAddressWithPort(value) - if (parsed) { - ports.push({ pid: currentPid, processName: currentProcessName, ...parsed }) - } - } - } - - return dedupeRawPorts(ports) -} - -export function parseNetstatListeningOutput(output: string): RawListeningPort[] { - const ports: RawListeningPort[] = [] - for (const line of output.split('\n')) { - const fields = getProcessOutputFields(line, 6) - if (fields[0]?.toUpperCase() !== 'TCP') { - continue - } - const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING') - if (stateIndex < 2) { - continue - } - const parsed = parseAddressWithPort(fields[1]) - const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10) - if (!parsed) { - continue - } - ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined }) - } - return dedupeRawPorts(ports) -} - -export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] { - const results: { host: string; port: number; inode: number }[] = [] - const lines = content.split('\n') - for (let i = 1; i < lines.length; i++) { - const fields = getProcessOutputFields(lines[i], 10) - if (fields.length < 10 || fields[3] !== '0A') { - continue - } - const parsed = parseProcAddress(fields[1]) - const inode = Number.parseInt(fields[9], 10) - if (!parsed || !Number.isFinite(inode) || inode === 0) { - continue - } - results.push({ ...parsed, inode }) - } - return results -} - -async function scanPlatformListeningPorts( - options: WorkspacePortScanOptions -): Promise { - const scan = await dispatchPlatformListeningPortScan(options) - if (scan.metadataAvailable) { - rememberListenerMetadata(scan.ports) - return scan - } - return { ...scan, ports: scan.ports.map(recallListenerMetadata) } -} - -async function dispatchPlatformListeningPortScan( - options: WorkspacePortScanOptions -): Promise { - if (process.platform === 'linux') { - return scanLinuxProcPorts() - } - if (process.platform === 'darwin') { - return scanDarwinLsofPorts(options) - } - if (process.platform === 'win32') { - return scanWindowsNetstatPorts(options) - } - throw new Error(`Port scanning is not supported on ${process.platform}`) -} - -async function scanDarwinLsofPorts( - options: WorkspacePortScanOptions -): Promise { - const { stdout, spawnMs } = await runPortScanCommand('lsof', [ - '-nP', - '-iTCP', - '-sTCP:LISTEN', - '-F', - 'pcn' - ]) - const ports = parseLsofListeningOutput(stdout) - if (shouldSkipMetadataCommands(spawnMs, options)) { - return { ports, metadataAvailable: false } - } - const metadata = await loadDarwinProcessMetadata( - new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) - ) - return { - ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), - metadataAvailable: true - } -} - -async function scanWindowsNetstatPorts( - options: WorkspacePortScanOptions -): Promise { - const { stdout, spawnMs } = await runPortScanCommand('netstat', ['-ano', '-p', 'tcp']) - const ports = parseNetstatListeningOutput(stdout) - if (shouldSkipMetadataCommands(spawnMs, options)) { - return { ports, metadataAvailable: false } - } - const metadata = await loadWindowsProcessMetadata( - new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) - ) - return { - ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), - metadataAvailable: true - } -} - -async function scanLinuxProcPorts(): Promise { - const [tcp4, tcp6] = await Promise.all([ - readProcNet('/proc/net/tcp'), - readProcNet('/proc/net/tcp6') - ]) - const sockets = [...tcp4, ...tcp6] - const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode))) - const metadata = new Map() - const rawPorts: RawListeningPort[] = [] - - for (const socket of sockets) { - const pid = inodeToPid.get(socket.inode) - if (pid != null && !metadata.has(pid)) { - metadata.set(pid, await loadLinuxProcessMetadata(pid)) - } - rawPorts.push({ - host: socket.host, - port: socket.port, - pid, - ...metadata.get(pid ?? -1) - }) - } - - return { ports: dedupeRawPorts(rawPorts), metadataAvailable: true } -} - -async function readProcNet( - filePath: string -): Promise<{ host: string; port: number; inode: number }[]> { - try { - return parseProcNetTcp(await readFile(filePath, 'utf-8')) - } catch { - return [] - } -} - -async function mapLinuxInodesToPids(inodes: Set): Promise> { - const result = new Map() - if (inodes.size === 0) { - return result - } - let pids: string[] - try { - pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) - } catch { - return result - } - - for (const pidText of pids) { - let fds: string[] - try { - fds = await readdir(`/proc/${pidText}/fd`) - } catch { - continue - } - const pid = Number.parseInt(pidText, 10) - for (const fd of fds) { - let link: string - try { - link = await readlink(`/proc/${pidText}/fd/${fd}`) - } catch { - continue - } - const match = link.match(/^socket:\[(\d+)\]$/) - if (!match) { - continue - } - const inode = Number.parseInt(match[1], 10) - if (inodes.has(inode)) { - result.set(inode, pid) - } - } - } - return result -} - -async function loadLinuxProcessMetadata(pid: number): Promise { - const [comm, cmdline, cwd] = await Promise.all([ - readTextIfAvailable(`/proc/${pid}/comm`), - readTextIfAvailable(`/proc/${pid}/cmdline`), - readlink(`/proc/${pid}/cwd`).catch(() => undefined) - ]) - return { - processName: comm?.trim() || undefined, - commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined, - cwd - } -} - -async function loadDarwinProcessMetadata(pids: Set): Promise> { - const result = new Map() - const pidList = Array.from(pids).join(',') - if (!pidList) { - return result - } - - // Why (#11161): sequential, not Promise.all — the probe worker dispatches one - // command at a time, so issuing both at once would only queue the second. - const cwdOutput = await runPortScanCommand('lsof', [ - '-a', - '-p', - pidList, - '-d', - 'cwd', - '-Fn' - ]).catch(() => null) - const commandOutput = await runPortScanCommand('ps', [ - '-p', - pidList, - '-o', - 'pid=', - '-o', - 'command=' - ]).catch(() => null) - - let currentPid: number | null = null - for (const line of cwdOutput?.stdout.split('\n') ?? []) { - if (line.startsWith('p')) { - const pid = Number.parseInt(line.slice(1), 10) - currentPid = Number.isFinite(pid) ? pid : null - } else if (line.startsWith('n') && currentPid != null) { - result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined }) - } - } - - for (const line of commandOutput?.stdout.split('\n') ?? []) { - const match = line.match(/^\s*(\d+)\s+(.+)$/) - if (!match) { - continue - } - const pid = Number.parseInt(match[1], 10) - result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined }) - } - - return result -} - -async function loadWindowsProcessMetadata( - pids: Set -): Promise> { - const result = new Map() - if (pids.size === 0) { - return result - } - try { - // Why the native snapshot: attributing ports used to fork a powershell.exe - // per scan just to turn PIDs into names. That is a ~700ms cold start, a - // conhost window, and one more thing a Group Policy can block -- for data - // the panel treats as optional anyway. - for (const row of await readWindowsProcessTable()) { - if (pids.has(row.pid)) { - result.set(row.pid, { - processName: row.name, - commandLine: row.command || undefined - }) - } - } - } catch { - // Process metadata is optional; port rows still render without attribution. - } - return result -} - -function isCommandTimeoutError(error: unknown): boolean { - return error instanceof PortScanCommandTimeoutError -} - -async function readTextIfAvailable(filePath: string): Promise { - try { - return await readFile(filePath, 'utf-8') - } catch { - return undefined - } -} - -function enrichPort( - port: RawListeningPort, - worktrees: readonly NormalizedWorkspacePortProbe[], - urlWatcher: Pick -): WorkspacePort { - const owner = attributePortToNormalizedWorkspaces(port, worktrees) - const base = { - id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`, - bindHost: port.host, - connectHost: connectHostForBindHost(port.host), - port: port.port, - pid: port.pid, - processName: port.processName, - protocol: inferProtocol(port.port) - } - - if (owner) { - // Why: only enrich workspace-attributed ports. Container and external - // ports may have URLs printed in unrelated terminals — the worktree - // scoping is the primary false-positive filter. - const advertised = urlWatcher.lookup(owner.worktreeId, port.port, port.pid) - return { - ...base, - protocol: advertised?.protocol ?? base.protocol, - kind: 'workspace', - owner, - ...(advertised ? { advertisedUrl: advertised.origin } : {}) - } - } - if (isContainerProcess(port)) { - return { ...base, kind: 'container' } - } - return { ...base, kind: 'external' } -} - -function reconcileAdvertisedUrls( - ports: RawListeningPort[], - worktrees: readonly NormalizedWorkspacePortProbe[], - urlWatcher: Pick -): void { - const observationsByWorktree = new Map() - for (const worktree of worktrees) { - observationsByWorktree.set(worktree.worktree.id, []) - } - for (const port of ports) { - const owner = attributePortToNormalizedWorkspaces(port, worktrees) - if (!owner) { - continue - } - observationsByWorktree.get(owner.worktreeId)?.push({ port: port.port, pid: port.pid }) - } - for (const [worktreeId, observations] of observationsByWorktree) { - // Why: the scanner sees port disappearance and PID changes before a lazy - // lookup would otherwise pin a stale banner to a new listener. - urlWatcher.reconcileScan([worktreeId], observations) - } -} - -function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number { - const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2 - const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2 - return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost) -} - -function inferProtocol(port: number): 'http' | 'https' | 'unknown' { - if (HTTPS_PORTS.has(port)) { - return 'https' - } - if (HTTP_PORTS.has(port)) { - return 'http' - } - return 'unknown' -} - -export function isContainerProcess( - port: Pick -): boolean { - const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase() - return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack) -} - -function toOwner( - worktree: WorkspacePortProbe, - confidence: WorkspacePortOwner['confidence'] -): WorkspacePortOwner { - return { - worktreeId: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.path, - confidence - } -} - -function pickDeepestMatching( - candidates: readonly T[], - predicate: (candidate: T) => boolean -): T | undefined { - let best: T | undefined - for (const candidate of candidates) { - if (!predicate(candidate)) { - continue - } - if (!best || candidate.normalizedPath.length > best.normalizedPath.length) { - best = candidate - } - } - return best -} - -function isSameOrDescendant(candidate: string, parent: string): boolean { - return candidate === parent || candidate.startsWith(`${parent}/`) -} - -function includesPathBoundary(commandLine: string, normalizedPath: string): boolean { - let index = commandLine.indexOf(normalizedPath) - while (index !== -1) { - const before = index === 0 ? '' : commandLine[index - 1] - const after = commandLine[index + normalizedPath.length] ?? '' - const startsOnBoundary = before === '' || /\s|["'=]/.test(before) - const endsOnBoundary = after === '' || /[\s"'/:]/.test(after) - if (startsOnBoundary && endsOnBoundary) { - return true - } - index = commandLine.indexOf(normalizedPath, index + normalizedPath.length) - } - return false -} - -function normalizeComparablePath(input: string): string { - if (input.startsWith('/')) { - // Why: command-line evidence for SSH/WSL/POSIX workspaces can be evaluated - // on a Windows host; path.resolve would reinterpret "/repo" as "G:/repo". - return normalizeComparableText(path.posix.resolve(input)) - } - return normalizeComparableText(path.resolve(input)) -} - -function normalizeComparableText(input: string): string { - const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/') - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function connectHostForBindHost(host: string): string { - if (host === '*' || host === '0.0.0.0' || host === '::') { - return 'localhost' - } - return host -} - -function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] { - const seen = new Set() - const result: RawListeningPort[] = [] - for (const port of ports) { - const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}` - if (seen.has(key)) { - continue - } - seen.add(key) - result.push(port) - } - return result -} - -function parseAddressWithPort(value: string): { host: string; port: number } | null { - const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '') - const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/) - if (bracketed) { - return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) } - } - const match = trimmed.match(/^(.+):(\d+)$/) - if (!match) { - return null - } - const port = Number.parseInt(match[2], 10) - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - return null - } - return { host: match[1], port } -} - -function parseProcAddress(hexAddress: string): { host: string; port: number } | null { - const [addrHex, portHex] = hexAddress.split(':') - const port = Number.parseInt(portHex, 16) - if (!Number.isFinite(port) || port === 0) { - return null - } - if (addrHex.length === 8) { - const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16)) - return { host: bytes.join('.'), port } - } - if (addrHex.length === 32) { - if (addrHex === '00000000000000000000000000000000') { - return { host: '::', port } - } - if (addrHex === '00000000000000000000000001000000') { - return { host: '::1', port } - } - return { host: formatIPv6Address(addrHex), port } - } - return null -} - -function formatIPv6Address(hex: string): string { - const groups: string[] = [] - for (let i = 0; i < 32; i += 8) { - const chunk = hex.slice(i, i + 8) - const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2) - groups.push(reversed.slice(0, 4), reversed.slice(4, 8)) - } - return groups.map((group) => group.replace(/^0+/, '') || '0').join(':') -} diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index 31c7d193c62..754172d3b42 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -8,7 +8,8 @@ import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' -import { scanWorkspacePorts, type WorkspacePortScanOptions } from './local-workspace-port-scanner' +import { scanWorkspacePorts } from './local-workspace-port-scanner' +import type { WorkspacePortScanOptions } from './local-workspace-port-scan-state' export type WorkspacePortProbeInput = WorkspacePortProbe & { connectionId?: string | null diff --git a/tests/e2e/helpers/terminal-active-pane.ts b/tests/e2e/helpers/terminal-active-pane.ts new file mode 100644 index 00000000000..b5987c61f3a --- /dev/null +++ b/tests/e2e/helpers/terminal-active-pane.ts @@ -0,0 +1,159 @@ +import { expect, type Page } from '@stablyai/playwright-test' +import { buildFreshShellProbeInputSequence } from '../terminal-probe-input-sequence' +import { + getTerminalContent, + resolveActiveTabId, + type ActivePaneHookDescriptor +} from './terminal-pane-identity' + +export async function waitForActivePaneHookDescriptor( + page: Page, + timeoutMs = 15_000 +): Promise { + let descriptor: ActivePaneHookDescriptor | null = null + await expect + .poll( + async () => { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + descriptor = null + return false + } + descriptor = await page.evaluate((tabId) => { + const layoutHasLeaf = (node: unknown, targetLeafId: string): boolean => { + if (!node || typeof node !== 'object') { + return false + } + const record = node as { + type?: unknown + leafId?: unknown + first?: unknown + second?: unknown + } + if (record.type === 'leaf') { + return record.leafId === targetLeafId + } + return ( + layoutHasLeaf(record.first, targetLeafId) || + layoutHasLeaf(record.second, targetLeafId) + ) + } + + const store = window.__store + const manager = window.__paneManagers?.get(tabId) + if (!store || !manager) { + return null + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if ( + !worktreeId || + !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) + ) { + return null + } + + const activePane = manager.getActivePane?.() ?? manager.getPanes?.()[0] + const leafId = activePane?.leafId ?? null + const layout = state.terminalLayoutsByTabId[tabId] + if ( + !leafId || + !layoutHasLeaf(layout?.root, leafId) || + layout?.ptyIdsByLeafId?.[leafId] !== activePane?.container?.dataset?.ptyId + ) { + return null + } + return { paneKey: `${tabId}:${leafId}`, worktreeId } + }, tabId) + return descriptor !== null + }, + { + timeout: timeoutMs, + // Why: hook IPC routing drops statuses for pane keys before the store + // layout knows that leaf, even if the terminal DOM already has a PTY. + message: 'Active terminal pane did not become routable for hook status IPC' + } + ) + .toBe(true) + + if (!descriptor) { + throw new Error('Active terminal pane descriptor disappeared after routing wait') + } + return descriptor +} + +// Why: PTY IDs are opaque integers not exposed in the DOM. Probe each +// candidate with a unique marker and read back via SerializeAddon. +export async function discoverActivePtyId(page: Page): Promise { + const marker = `__PTY_PROBE_${Date.now()}__` + + const readCandidateIds = async (): Promise => { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return [] + } + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return [] + } + return store.getState().ptyIdsByTabId[tabId] ?? [] + }, tabId) + } + + await expect + .poll(readCandidateIds, { + timeout: 15_000, + message: 'discoverActivePtyId: active tab never received PTY candidates' + }) + .not.toEqual([]) + + const candidateIds = await readCandidateIds() + + if (candidateIds.length === 0) { + // Why: blind-probing arbitrary PTY IDs can write into unrelated shells and + // hides real regressions in the tab->PTY mapping the test depends on. + throw new Error('discoverActivePtyId: active tab has no PTY candidates in store') + } + + const candidateInputs = candidateIds.map((_id, index) => + buildFreshShellProbeInputSequence(`echo ${marker}_${index}\r`) + ) + + await page.evaluate( + ({ candidateIds, candidateInputs }) => { + // Why: daemon PTY IDs can contain path separators and shell metacharacters. + // Echo a numeric probe index, then map it back to the opaque ID in Node. + for (const [index, id] of candidateIds.entries()) { + for (const input of candidateInputs[index] ?? []) { + window.api.pty.write(String(id), input) + } + } + }, + { candidateIds, candidateInputs } + ) + + let foundPtyId: string | null = null + await expect + .poll( + async () => { + const content = await getTerminalContent(page) + const markerRe = new RegExp(`${marker}_(\\d+)`, 'g') + const matches = [...content.matchAll(markerRe)] + if (matches.length > 0) { + const index = Number(matches.at(-1)?.[1] ?? Number.NaN) + foundPtyId = Number.isInteger(index) ? (candidateIds[index] ?? null) : null + return true + } + return false + }, + { timeout: 10_000, message: 'PTY marker did not appear in terminal buffer' } + ) + .toBe(true) + + if (!foundPtyId) { + throw new Error('discoverActivePtyId: no marker found in terminal buffer') + } + + return foundPtyId +} diff --git a/tests/e2e/helpers/terminal-pane-identity.ts b/tests/e2e/helpers/terminal-pane-identity.ts new file mode 100644 index 00000000000..bdc8f254b2b --- /dev/null +++ b/tests/e2e/helpers/terminal-pane-identity.ts @@ -0,0 +1,113 @@ +import type { Page } from '@stablyai/playwright-test' + +export type PaneIdentitySnapshot = { + tabId: string + activeLeafId: string | null + panes: { + numericPaneId: number + leafId: string + stablePaneId: string + datasetLeafId: string | null + ptyId: string | null + }[] + ptyIdsByLeafId: Record +} + +export type ActivePaneHookDescriptor = { + paneKey: string + worktreeId: string +} + +// Why: worktree restoration can render the terminal surface before the legacy +// global activeTabId settles. Prefer the active worktree's saved terminal tab +// pointer, then fall back to the first terminal tab. +export async function resolveActiveTabId(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return null + } + const state = store.getState() + const wId = state.activeWorktreeId + if (!wId) { + return null + } + const tabs = state.tabsByWorktree[wId] ?? [] + if (tabs.length === 0) { + return null + } + const pref = + state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[wId] ?? null) + if (pref && tabs.some((t) => t.id === pref)) { + return pref + } + return tabs[0]?.id ?? null + }) +} + +// Why: reads the buffer through the SerializeAddon that the PaneManager +// already loads for every terminal pane (exposed via VITE_EXPOSE_STORE). +export async function getTerminalContent(page: Page, charLimit = 4000): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return '' + } + return page.evaluate( + ({ tabId, charLimit }) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + return '' + } + + const manager = paneManagers.get(tabId) + if (!manager) { + return '' + } + + const activePane = manager.getActivePane?.() + if (!activePane) { + const panes = manager.getPanes?.() ?? [] + if (panes.length === 0) { + return '' + } + const text = panes[0].serializeAddon?.serialize?.() ?? '' + return text.slice(-charLimit) + } + + const text = activePane.serializeAddon?.serialize?.() ?? '' + return text.slice(-charLimit) + }, + { tabId, charLimit } + ) +} + +export async function readPaneIdentitySnapshot(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return null + } + + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const store = window.__store + if (!manager || !store) { + return null + } + + const activePane = manager.getActivePane?.() ?? null + return { + tabId, + activeLeafId: activePane?.leafId ?? null, + panes: manager.getPanes().map((pane) => ({ + numericPaneId: pane.id, + leafId: pane.leafId, + stablePaneId: pane.stablePaneId, + datasetLeafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null + })), + ptyIdsByLeafId: store.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} + } + }, tabId) +} diff --git a/tests/e2e/helpers/terminal-pane-operations.ts b/tests/e2e/helpers/terminal-pane-operations.ts new file mode 100644 index 00000000000..d3d488f35e3 --- /dev/null +++ b/tests/e2e/helpers/terminal-pane-operations.ts @@ -0,0 +1,226 @@ +import { expect, type Page } from '@stablyai/playwright-test' +import { + getTerminalContent, + readPaneIdentitySnapshot, + resolveActiveTabId +} from './terminal-pane-identity' + +export async function readTerminalPaneDomLeafOrder(page: Page): Promise { + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + return [] + } + + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + return [] + } + const paneElements = new Set(manager.getPanes().map((pane) => pane.container)) + return Array.from(document.querySelectorAll('.pane[data-leaf-id]')) + .filter((element) => paneElements.has(element)) + .map((element) => element.dataset.leafId ?? '') + .filter((leafId) => leafId.length > 0) + }, snapshot.tabId) +} + +export async function moveTerminalPaneByLeafId( + page: Page, + sourceLeafId: string, + targetLeafId: string, + zone: 'top' | 'bottom' | 'left' | 'right' +): Promise { + const snapshot = await readPaneIdentitySnapshot(page) + if (!snapshot) { + throw new Error('moveTerminalPaneByLeafId: no active terminal tab') + } + + await page.evaluate( + ({ tabId, sourceLeafId, targetLeafId, zone }) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error('moveTerminalPaneByLeafId: active pane manager not ready') + } + const sourcePaneId = manager.getNumericIdForLeaf(sourceLeafId) + const targetPaneId = manager.getNumericIdForLeaf(targetLeafId) + if (sourcePaneId == null || targetPaneId == null) { + throw new Error('moveTerminalPaneByLeafId: source or target leaf is not mounted') + } + manager.movePane(sourcePaneId, targetPaneId, zone) + }, + { tabId: snapshot.tabId, sourceLeafId, targetLeafId, zone } + ) +} + +export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { + await page.evaluate( + ({ ptyId, text }) => { + window.api.pty.write(ptyId, text) + }, + { ptyId, text } + ) +} + +export async function execInTerminal(page: Page, ptyId: string, command: string): Promise { + await sendToTerminal(page, ptyId, `${command}\r`) +} + +export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { + await expect + .poll( + async () => { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return false + } + return page.evaluate((tabId) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + return false + } + return (paneManagers.get(tabId)?.getPanes?.().length ?? 0) > 0 + }, tabId) + }, + { + timeout: timeoutMs, + message: 'Active terminal PaneManager did not finish mounting' + } + ) + .toBe(true) +} + +export async function splitActiveTerminalPane( + page: Page, + direction: 'vertical' | 'horizontal' +): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + throw new Error('splitActiveTerminalPane: no active terminal tab') + } + await page.evaluate( + ({ tabId, direction }) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + throw new Error('splitActiveTerminalPane: terminal store/manager unavailable') + } + + const manager = paneManagers.get(tabId) + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!manager?.splitPane || !activePane) { + throw new Error('splitActiveTerminalPane: active pane manager not ready') + } + + // Why: Electron key delivery to the terminal pane layer is flaky in E2E + // even when the visible pane tree is mounted. Driving the active + // PaneManager directly still exercises the real split/layout/PTY path + // without depending on window-focus timing. + manager.splitPane(activePane.id, direction) + }, + { tabId, direction } + ) +} + +export async function closeActiveTerminalPane(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + throw new Error('closeActiveTerminalPane: no active terminal tab') + } + await page.evaluate((tabId) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + throw new Error('closeActiveTerminalPane: terminal store/manager unavailable') + } + + const manager = paneManagers.get(tabId) + const panes = manager?.getPanes?.() ?? [] + if (!manager?.closePane || panes.length < 2) { + return + } + + const activePane = manager.getActivePane?.() ?? panes[0] + if (!activePane) { + return + } + + manager.closePane(activePane.id) + }, tabId) +} + +export async function focusLastTerminalPane(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + throw new Error('focusLastTerminalPane: no active terminal tab') + } + await page.evaluate((tabId) => { + const paneManagers = window.__paneManagers + if (!paneManagers) { + throw new Error('focusLastTerminalPane: terminal store/manager unavailable') + } + + const manager = paneManagers.get(tabId) + const panes = manager?.getPanes?.() ?? [] + const lastPane = panes.at(-1) ?? null + if (!manager?.setActivePane || !lastPane) { + throw new Error('focusLastTerminalPane: active pane manager not ready') + } + + manager.setActivePane(lastPane.id, { focus: true }) + }, tabId) +} + +// Why: hidden-window E2E mode keeps DOM visibility signals false. The pane +// manager tracks the authoritative active split layout independently of CSS. +export async function countVisibleTerminalPanes(page: Page): Promise { + const tabId = await resolveActiveTabId(page) + if (!tabId) { + return 0 + } + return page.evaluate((tabId) => { + const managerCount = window.__paneManagers?.get(tabId)?.getPanes?.().length ?? 0 + if (managerCount > 0) { + return managerCount + } + + const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] + if (!layout) { + return 0 + } + + // Why: `root: null` means the default single-pane tab (no splits yet). + type N = { type: 'leaf' } | { type: 'split'; first: N | null; second: N | null } | null + const countLeaves = (node: N): number => { + if (!node || node.type === 'leaf') { + return 1 + } + return countLeaves(node.first) + countLeaves(node.second) + } + return countLeaves(layout.root as N) + }, tabId) +} + +export async function waitForTerminalOutput( + page: Page, + expected: string, + timeoutMs = 10_000, + charLimit = 4000 +): Promise { + await expect + .poll(async () => (await getTerminalContent(page, charLimit)).includes(expected), { + timeout: timeoutMs, + message: `Terminal did not contain "${expected}"` + }) + .toBe(true) +} + +export async function waitForPaneCount( + page: Page, + expectedCount: number, + timeoutMs = 10_000 +): Promise { + await expect + .poll(async () => countVisibleTerminalPanes(page), { + timeout: timeoutMs, + message: `Expected ${expectedCount} visible terminal panes` + }) + .toBe(expectedCount) +} diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index ec55b702c39..ddfaf506156 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -1,28 +1,40 @@ -/* eslint-disable max-lines -- Terminal E2E helpers share one PaneManager-backed path for PTY IO, split actions, and stable pane identity snapshots. */ import type { Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' -import { buildFreshShellProbeInputSequence } from '../terminal-probe-input-sequence' +import { + getTerminalContent, + readPaneIdentitySnapshot, + resolveActiveTabId, + type ActivePaneHookDescriptor, + type PaneIdentitySnapshot +} from './terminal-pane-identity' +import { + discoverActivePtyId as discoverActivePtyIdImpl, + waitForActivePaneHookDescriptor as waitForActivePaneHookDescriptorImpl +} from './terminal-active-pane' +import { + closeActiveTerminalPane as closeActiveTerminalPaneImpl, + countVisibleTerminalPanes as countVisibleTerminalPanesImpl, + execInTerminal as execInTerminalImpl, + focusLastTerminalPane as focusLastTerminalPaneImpl, + moveTerminalPaneByLeafId as moveTerminalPaneByLeafIdImpl, + readTerminalPaneDomLeafOrder as readTerminalPaneDomLeafOrderImpl, + sendToTerminal as sendToTerminalImpl, + splitActiveTerminalPane as splitActiveTerminalPaneImpl, + waitForActiveTerminalManager as waitForActiveTerminalManagerImpl, + waitForPaneCount as waitForPaneCountImpl, + waitForTerminalOutput as waitForTerminalOutputImpl +} from './terminal-pane-operations' + +export { + getTerminalContent, + readPaneIdentitySnapshot, + resolveActiveTabId, + type ActivePaneHookDescriptor, + type PaneIdentitySnapshot +} export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ -export type PaneIdentitySnapshot = { - tabId: string - activeLeafId: string | null - panes: { - numericPaneId: number - leafId: string - stablePaneId: string - datasetLeafId: string | null - ptyId: string | null - }[] - ptyIdsByLeafId: Record -} - -export type ActivePaneHookDescriptor = { - paneKey: string - worktreeId: string -} - // Why: typing-latency specs must type into xterm's helper textarea, not the // page body — keyboard.type only reaches the PTY when that textarea has focus. export async function focusActiveTerminalInput(page: Page): Promise { @@ -53,71 +65,6 @@ export async function focusActiveTerminalInput(page: Page): Promise { }) } -// Why: worktree restoration can render the terminal surface before the legacy -// global activeTabId settles. Prefer the active worktree's saved terminal tab -// pointer, then fall back to the first terminal tab. -async function resolveActiveTabId(page: Page): Promise { - return page.evaluate(() => { - const store = window.__store - if (!store) { - return null - } - const state = store.getState() - const wId = state.activeWorktreeId - if (!wId) { - return null - } - const tabs = state.tabsByWorktree[wId] ?? [] - if (tabs.length === 0) { - return null - } - const pref = - state.activeTabType === 'terminal' - ? state.activeTabId - : (state.activeTabIdByWorktree?.[wId] ?? null) - if (pref && tabs.some((t) => t.id === pref)) { - return pref - } - return tabs[0]?.id ?? null - }) -} - -// Why: reads the buffer through the SerializeAddon that the PaneManager -// already loads for every terminal pane (exposed via VITE_EXPOSE_STORE). -export async function getTerminalContent(page: Page, charLimit = 4000): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return '' - } - return page.evaluate( - ({ tabId, charLimit }) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - return '' - } - - const manager = paneManagers.get(tabId) - if (!manager) { - return '' - } - - const activePane = manager.getActivePane?.() - if (!activePane) { - const panes = manager.getPanes?.() ?? [] - if (panes.length === 0) { - return '' - } - const text = panes[0].serializeAddon?.serialize?.() ?? '' - return text.slice(-charLimit) - } - - const text = activePane.serializeAddon?.serialize?.() ?? '' - return text.slice(-charLimit) - }, - { tabId, charLimit } - ) -} - export async function waitForActivePanePtyId(page: Page, timeoutMs = 15_000): Promise { let resolvedPtyId: string | null = null await expect @@ -148,187 +95,6 @@ export async function waitForActivePanePtyId(page: Page, timeoutMs = 15_000): Pr return resolvedPtyId } -export async function waitForActivePaneHookDescriptor( - page: Page, - timeoutMs = 15_000 -): Promise { - let descriptor: ActivePaneHookDescriptor | null = null - await expect - .poll( - async () => { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - descriptor = null - return false - } - descriptor = await page.evaluate((tabId) => { - const layoutHasLeaf = (node: unknown, targetLeafId: string): boolean => { - if (!node || typeof node !== 'object') { - return false - } - const record = node as { - type?: unknown - leafId?: unknown - first?: unknown - second?: unknown - } - if (record.type === 'leaf') { - return record.leafId === targetLeafId - } - return ( - layoutHasLeaf(record.first, targetLeafId) || - layoutHasLeaf(record.second, targetLeafId) - ) - } - - const store = window.__store - const manager = window.__paneManagers?.get(tabId) - if (!store || !manager) { - return null - } - const state = store.getState() - const worktreeId = state.activeWorktreeId - if ( - !worktreeId || - !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) - ) { - return null - } - - const activePane = manager.getActivePane?.() ?? manager.getPanes?.()[0] - const leafId = activePane?.leafId ?? null - const layout = state.terminalLayoutsByTabId[tabId] - if ( - !leafId || - !layoutHasLeaf(layout?.root, leafId) || - layout?.ptyIdsByLeafId?.[leafId] !== activePane?.container?.dataset?.ptyId - ) { - return null - } - return { paneKey: `${tabId}:${leafId}`, worktreeId } - }, tabId) - return descriptor !== null - }, - { - timeout: timeoutMs, - // Why: hook IPC routing drops statuses for pane keys before the store - // layout knows that leaf, even if the terminal DOM already has a PTY. - message: 'Active terminal pane did not become routable for hook status IPC' - } - ) - .toBe(true) - - if (!descriptor) { - throw new Error('Active terminal pane descriptor disappeared after routing wait') - } - return descriptor -} - -// Why: PTY IDs are opaque integers not exposed in the DOM. Probe each -// candidate with a unique marker and read back via SerializeAddon. -export async function discoverActivePtyId(page: Page): Promise { - const marker = `__PTY_PROBE_${Date.now()}__` - - const readCandidateIds = async (): Promise => { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return [] - } - return page.evaluate((tabId) => { - const store = window.__store - if (!store) { - return [] - } - return store.getState().ptyIdsByTabId[tabId] ?? [] - }, tabId) - } - - await expect - .poll(readCandidateIds, { - timeout: 15_000, - message: 'discoverActivePtyId: active tab never received PTY candidates' - }) - .not.toEqual([]) - - const candidateIds = await readCandidateIds() - - if (candidateIds.length === 0) { - // Why: blind-probing arbitrary PTY IDs can write into unrelated shells and - // hides real regressions in the tab->PTY mapping the test depends on. - throw new Error('discoverActivePtyId: active tab has no PTY candidates in store') - } - - const candidateInputs = candidateIds.map((_id, index) => - buildFreshShellProbeInputSequence(`echo ${marker}_${index}\r`) - ) - - await page.evaluate( - ({ candidateIds, candidateInputs }) => { - // Why: daemon PTY IDs can contain path separators and shell metacharacters. - // Echo a numeric probe index, then map it back to the opaque ID in Node. - for (const [index, id] of candidateIds.entries()) { - for (const input of candidateInputs[index] ?? []) { - window.api.pty.write(String(id), input) - } - } - }, - { candidateIds, candidateInputs } - ) - - let foundPtyId: string | null = null - await expect - .poll( - async () => { - const content = await getTerminalContent(page) - const markerRe = new RegExp(`${marker}_(\\d+)`, 'g') - const matches = [...content.matchAll(markerRe)] - if (matches.length > 0) { - const index = Number(matches.at(-1)?.[1] ?? Number.NaN) - foundPtyId = Number.isInteger(index) ? (candidateIds[index] ?? null) : null - return true - } - return false - }, - { timeout: 10_000, message: 'PTY marker did not appear in terminal buffer' } - ) - .toBe(true) - - if (!foundPtyId) { - throw new Error('discoverActivePtyId: no marker found in terminal buffer') - } - - return foundPtyId -} - -export async function readPaneIdentitySnapshot(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return null - } - - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const store = window.__store - if (!manager || !store) { - return null - } - - const activePane = manager.getActivePane?.() ?? null - return { - tabId, - activeLeafId: activePane?.leafId ?? null, - panes: manager.getPanes().map((pane) => ({ - numericPaneId: pane.id, - leafId: pane.leafId, - stablePaneId: pane.stablePaneId, - datasetLeafId: pane.container.dataset.leafId ?? null, - ptyId: pane.container.dataset.ptyId ?? null - })), - ptyIdsByLeafId: store.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} - } - }, tabId) -} - export async function waitForPaneIdentitySnapshot( page: Page, paneCount: number @@ -364,222 +130,74 @@ export async function waitForPaneIdentitySnapshot( return snapshot } -export async function readTerminalPaneDomLeafOrder(page: Page): Promise { - const snapshot = await readPaneIdentitySnapshot(page) - if (!snapshot) { - return [] - } - - return page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - return [] - } - const paneElements = new Set(manager.getPanes().map((pane) => pane.container)) - return Array.from(document.querySelectorAll('.pane[data-leaf-id]')) - .filter((element) => paneElements.has(element)) - .map((element) => element.dataset.leafId ?? '') - .filter((leafId) => leafId.length > 0) - }, snapshot.tabId) +export function waitForActivePaneHookDescriptor( + page: Page, + timeoutMs = 15_000 +): Promise { + return waitForActivePaneHookDescriptorImpl(page, timeoutMs) } -export async function moveTerminalPaneByLeafId( +export function discoverActivePtyId(page: Page): Promise { + return discoverActivePtyIdImpl(page) +} + +export function readTerminalPaneDomLeafOrder(page: Page): Promise { + return readTerminalPaneDomLeafOrderImpl(page) +} + +export function moveTerminalPaneByLeafId( page: Page, sourceLeafId: string, targetLeafId: string, zone: 'top' | 'bottom' | 'left' | 'right' ): Promise { - const snapshot = await readPaneIdentitySnapshot(page) - if (!snapshot) { - throw new Error('moveTerminalPaneByLeafId: no active terminal tab') - } - - await page.evaluate( - ({ tabId, sourceLeafId, targetLeafId, zone }) => { - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - throw new Error('moveTerminalPaneByLeafId: active pane manager not ready') - } - const sourcePaneId = manager.getNumericIdForLeaf(sourceLeafId) - const targetPaneId = manager.getNumericIdForLeaf(targetLeafId) - if (sourcePaneId == null || targetPaneId == null) { - throw new Error('moveTerminalPaneByLeafId: source or target leaf is not mounted') - } - manager.movePane(sourcePaneId, targetPaneId, zone) - }, - { tabId: snapshot.tabId, sourceLeafId, targetLeafId, zone } - ) + return moveTerminalPaneByLeafIdImpl(page, sourceLeafId, targetLeafId, zone) } -export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { - await page.evaluate( - ({ ptyId, text }) => { - window.api.pty.write(ptyId, text) - }, - { ptyId, text } - ) +export function sendToTerminal(page: Page, ptyId: string, text: string): Promise { + return sendToTerminalImpl(page, ptyId, text) } -export async function execInTerminal(page: Page, ptyId: string, command: string): Promise { - await sendToTerminal(page, ptyId, `${command}\r`) +export function execInTerminal(page: Page, ptyId: string, command: string): Promise { + return execInTerminalImpl(page, ptyId, command) } -export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { - await expect - .poll( - async () => { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return false - } - return page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - return false - } - return (paneManagers.get(tabId)?.getPanes?.().length ?? 0) > 0 - }, tabId) - }, - { - timeout: timeoutMs, - message: 'Active terminal PaneManager did not finish mounting' - } - ) - .toBe(true) +export function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { + return waitForActiveTerminalManagerImpl(page, timeoutMs) } -export async function splitActiveTerminalPane( +export function splitActiveTerminalPane( page: Page, direction: 'vertical' | 'horizontal' ): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - throw new Error('splitActiveTerminalPane: no active terminal tab') - } - await page.evaluate( - ({ tabId, direction }) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('splitActiveTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!manager?.splitPane || !activePane) { - throw new Error('splitActiveTerminalPane: active pane manager not ready') - } - - // Why: Electron key delivery to the terminal pane layer is flaky in E2E - // even when the visible pane tree is mounted. Driving the active - // PaneManager directly still exercises the real split/layout/PTY path - // without depending on window-focus timing. - manager.splitPane(activePane.id, direction) - }, - { tabId, direction } - ) + return splitActiveTerminalPaneImpl(page, direction) } -export async function closeActiveTerminalPane(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - throw new Error('closeActiveTerminalPane: no active terminal tab') - } - await page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('closeActiveTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const panes = manager?.getPanes?.() ?? [] - if (!manager?.closePane || panes.length < 2) { - return - } - - const activePane = manager.getActivePane?.() ?? panes[0] - if (!activePane) { - return - } - - manager.closePane(activePane.id) - }, tabId) +export function closeActiveTerminalPane(page: Page): Promise { + return closeActiveTerminalPaneImpl(page) } -export async function focusLastTerminalPane(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - throw new Error('focusLastTerminalPane: no active terminal tab') - } - await page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('focusLastTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const panes = manager?.getPanes?.() ?? [] - const lastPane = panes.at(-1) ?? null - if (!manager?.setActivePane || !lastPane) { - throw new Error('focusLastTerminalPane: active pane manager not ready') - } - - manager.setActivePane(lastPane.id, { focus: true }) - }, tabId) +export function focusLastTerminalPane(page: Page): Promise { + return focusLastTerminalPaneImpl(page) } -// Why: hidden-window E2E mode keeps DOM visibility signals false. The pane -// manager tracks the authoritative active split layout independently of CSS. -export async function countVisibleTerminalPanes(page: Page): Promise { - const tabId = await resolveActiveTabId(page) - if (!tabId) { - return 0 - } - return page.evaluate((tabId) => { - const managerCount = window.__paneManagers?.get(tabId)?.getPanes?.().length ?? 0 - if (managerCount > 0) { - return managerCount - } - - const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] - if (!layout) { - return 0 - } - - // Why: `root: null` means the default single-pane tab (no splits yet). - type N = { type: 'leaf' } | { type: 'split'; first: N | null; second: N | null } | null - const countLeaves = (node: N): number => { - if (!node || node.type === 'leaf') { - return 1 - } - return countLeaves(node.first) + countLeaves(node.second) - } - return countLeaves(layout.root as N) - }, tabId) +export function countVisibleTerminalPanes(page: Page): Promise { + return countVisibleTerminalPanesImpl(page) } -export async function waitForTerminalOutput( +export function waitForTerminalOutput( page: Page, expected: string, timeoutMs = 10_000, charLimit = 4000 ): Promise { - await expect - .poll(async () => (await getTerminalContent(page, charLimit)).includes(expected), { - timeout: timeoutMs, - message: `Terminal did not contain "${expected}"` - }) - .toBe(true) + return waitForTerminalOutputImpl(page, expected, timeoutMs, charLimit) } -export async function waitForPaneCount( +export function waitForPaneCount( page: Page, expectedCount: number, timeoutMs = 10_000 ): Promise { - await expect - .poll(async () => countVisibleTerminalPanes(page), { - timeout: timeoutMs, - message: `Expected ${expectedCount} visible terminal panes` - }) - .toBe(expectedCount) + return waitForPaneCountImpl(page, expectedCount, timeoutMs) }