Split port scanning and terminal helpers (#16765)

This commit is contained in:
Neil
2026-08-27 16:29:05 -07:00
committed by GitHub
parent 7ee8b5e1a6
commit 6c0de76ced
17 changed files with 1838 additions and 1589 deletions
-3
View File
@@ -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
}
+283
View File
@@ -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'
+52 -423
View File
@@ -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())
+24 -703
View File
@@ -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(':')
}
+2 -1
View File
@@ -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
+159
View File
@@ -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
}
+113
View File
@@ -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
View File
@@ -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)
}