mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Split port scanning and terminal helpers (#16765)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<CacheKey, AdvertisedUrl>
|
||||
validationBaselines: Map<CacheKey, ListenerScanState>
|
||||
startupAbsentAllowances: Set<CacheKey>
|
||||
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<CacheKey, AdvertisedUrl>
|
||||
validationBaselines: Map<CacheKey, ListenerScanState>
|
||||
startupAbsentAllowances: Set<CacheKey>
|
||||
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
|
||||
}
|
||||
@@ -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<number, number | undefined> {
|
||||
const observed = new Map<number, number | undefined>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -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<CacheKey, ListenerScanState>
|
||||
startupAbsentAllowances: Set<CacheKey>
|
||||
}): 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<CacheKey, AdvertisedUrl>
|
||||
validationBaselines: Map<CacheKey, ListenerScanState>
|
||||
startupAbsentAllowances: Set<CacheKey>
|
||||
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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<number, number | undefined> {
|
||||
const observed = new Map<number, number | undefined>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<number, ProcessMetadata>()
|
||||
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<number>): Promise<Map<number, number>> {
|
||||
const result = new Map<number, number>()
|
||||
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<ProcessMetadata> {
|
||||
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<number>): Promise<Map<number, ProcessMetadata>> {
|
||||
const result = new Map<number, ProcessMetadata>()
|
||||
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<number>
|
||||
): Promise<Map<number, ProcessMetadata>> {
|
||||
const result = new Map<number, ProcessMetadata>()
|
||||
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<string | undefined> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -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<string>()
|
||||
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(':')
|
||||
}
|
||||
@@ -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<number, true> = {
|
||||
80: true,
|
||||
3000: true,
|
||||
3001: true,
|
||||
4200: true,
|
||||
5000: true,
|
||||
5173: true,
|
||||
5174: true,
|
||||
8000: true,
|
||||
8080: true,
|
||||
8888: true
|
||||
}
|
||||
const HTTPS_PORTS: Record<number, true> = { 443: true, 8443: true }
|
||||
|
||||
export function attributePortToWorkspace(
|
||||
port: Pick<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
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<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
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<AdvertisedUrlWatcher, 'lookup'>
|
||||
): 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<AdvertisedUrlWatcher, 'reconcileScan'>
|
||||
): void {
|
||||
const observationsByWorktree = new Map<string, { port: number; pid?: number }[]>()
|
||||
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<RawListeningPort, 'processName' | 'commandLine'>
|
||||
): 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<T extends { normalizedPath: string }>(
|
||||
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
|
||||
}
|
||||
@@ -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<string, ProcessMetadata>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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<string, ProcessMetadata>()
|
||||
|
||||
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, 'lookup' | 'reconcileScan'> = advertisedUrlWatcher,
|
||||
options: WorkspacePortScanOptions = {}
|
||||
): Promise<WorkspacePortScanResult> {
|
||||
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<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
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<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<PlatformListeningPortScan> {
|
||||
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<number, ProcessMetadata>()
|
||||
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<number>): Promise<Map<number, number>> {
|
||||
const result = new Map<number, number>()
|
||||
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<ProcessMetadata> {
|
||||
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<number>): Promise<Map<number, ProcessMetadata>> {
|
||||
const result = new Map<number, ProcessMetadata>()
|
||||
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<number>
|
||||
): Promise<Map<number, ProcessMetadata>> {
|
||||
const result = new Map<number, ProcessMetadata>()
|
||||
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<string | undefined> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function enrichPort(
|
||||
port: RawListeningPort,
|
||||
worktrees: readonly NormalizedWorkspacePortProbe[],
|
||||
urlWatcher: Pick<AdvertisedUrlWatcher, 'lookup'>
|
||||
): 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<AdvertisedUrlWatcher, 'reconcileScan'>
|
||||
): void {
|
||||
const observationsByWorktree = new Map<string, { port: number; pid?: number }[]>()
|
||||
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<RawListeningPort, 'processName' | 'commandLine'>
|
||||
): 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<T extends { normalizedPath: string }>(
|
||||
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<string>()
|
||||
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(':')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ActivePaneHookDescriptor> {
|
||||
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<string> {
|
||||
const marker = `__PTY_PROBE_${Date.now()}__`
|
||||
|
||||
const readCandidateIds = async (): Promise<string[]> => {
|
||||
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
|
||||
}
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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<PaneIdentitySnapshot | null> {
|
||||
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)
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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<HTMLElement>('.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<void> {
|
||||
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<void> {
|
||||
await page.evaluate(
|
||||
({ ptyId, text }) => {
|
||||
window.api.pty.write(ptyId, text)
|
||||
},
|
||||
{ ptyId, text }
|
||||
)
|
||||
}
|
||||
|
||||
export async function execInTerminal(page: Page, ptyId: string, command: string): Promise<void> {
|
||||
await sendToTerminal(page, ptyId, `${command}\r`)
|
||||
}
|
||||
|
||||
export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await expect
|
||||
.poll(async () => countVisibleTerminalPanes(page), {
|
||||
timeout: timeoutMs,
|
||||
message: `Expected ${expectedCount} visible terminal panes`
|
||||
})
|
||||
.toBe(expectedCount)
|
||||
}
|
||||
+65
-447
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
@@ -53,71 +65,6 @@ export async function focusActiveTerminalInput(page: Page): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// 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<string | null> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<ActivePaneHookDescriptor> {
|
||||
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<string> {
|
||||
const marker = `__PTY_PROBE_${Date.now()}__`
|
||||
|
||||
const readCandidateIds = async (): Promise<string[]> => {
|
||||
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<PaneIdentitySnapshot | null> {
|
||||
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<string[]> {
|
||||
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<HTMLElement>('.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<ActivePaneHookDescriptor> {
|
||||
return waitForActivePaneHookDescriptorImpl(page, timeoutMs)
|
||||
}
|
||||
|
||||
export async function moveTerminalPaneByLeafId(
|
||||
export function discoverActivePtyId(page: Page): Promise<string> {
|
||||
return discoverActivePtyIdImpl(page)
|
||||
}
|
||||
|
||||
export function readTerminalPaneDomLeafOrder(page: Page): Promise<string[]> {
|
||||
return readTerminalPaneDomLeafOrderImpl(page)
|
||||
}
|
||||
|
||||
export function moveTerminalPaneByLeafId(
|
||||
page: Page,
|
||||
sourceLeafId: string,
|
||||
targetLeafId: string,
|
||||
zone: 'top' | 'bottom' | 'left' | 'right'
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await page.evaluate(
|
||||
({ ptyId, text }) => {
|
||||
window.api.pty.write(ptyId, text)
|
||||
},
|
||||
{ ptyId, text }
|
||||
)
|
||||
export function sendToTerminal(page: Page, ptyId: string, text: string): Promise<void> {
|
||||
return sendToTerminalImpl(page, ptyId, text)
|
||||
}
|
||||
|
||||
export async function execInTerminal(page: Page, ptyId: string, command: string): Promise<void> {
|
||||
await sendToTerminal(page, ptyId, `${command}\r`)
|
||||
export function execInTerminal(page: Page, ptyId: string, command: string): Promise<void> {
|
||||
return execInTerminalImpl(page, ptyId, command)
|
||||
}
|
||||
|
||||
export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise<void> {
|
||||
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<void> {
|
||||
return waitForActiveTerminalManagerImpl(page, timeoutMs)
|
||||
}
|
||||
|
||||
export async function splitActiveTerminalPane(
|
||||
export function splitActiveTerminalPane(
|
||||
page: Page,
|
||||
direction: 'vertical' | 'horizontal'
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return closeActiveTerminalPaneImpl(page)
|
||||
}
|
||||
|
||||
export async function focusLastTerminalPane(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
return countVisibleTerminalPanesImpl(page)
|
||||
}
|
||||
|
||||
export async function waitForTerminalOutput(
|
||||
export function waitForTerminalOutput(
|
||||
page: Page,
|
||||
expected: string,
|
||||
timeoutMs = 10_000,
|
||||
charLimit = 4000
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await expect
|
||||
.poll(async () => countVisibleTerminalPanes(page), {
|
||||
timeout: timeoutMs,
|
||||
message: `Expected ${expectedCount} visible terminal panes`
|
||||
})
|
||||
.toBe(expectedCount)
|
||||
return waitForPaneCountImpl(page, expectedCount, timeoutMs)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user