Move port scanning from shell to app-side with batching (#100)

* Move port scanning from shell to app-side with batching

Replace per-shell `ps -axo + lsof` scanning with a centralized
PortScanner singleton in the app. Each shell now sends lightweight
`report_tty` (once per session) and `ports_kick` (on preexec/precmd)
socket messages. The app coalesces kicks across all panels and runs a
single `ps -t <ttys> + lsof -p <pids>` covering every active panel.

Also fixes a macOS 26 Tahoe regression where `getsockopt(LOCAL_PEERPID)`
returns ENOTCONN on accepted sockets when the peer disconnects before
the handler thread starts. This was silently breaking ALL socket
commands sent via ncat --send-only. The fix captures the peer PID in
the accept loop immediately after accept(), and falls back to
LOCAL_PEERCRED (uid check) when the PID lookup fails.

* Fix PR review feedback: burst timing and auth comment clarity

- P2: burstDelays were accumulating (0.5+1.5+3+... = ~22.5s) instead of
  firing at absolute offsets from burst start. Now uses burstStart anchor
  so scans fire at 0.5s, 1.5s, 3s, 5s, 7.5s, 10s as intended.

- P1: Clarify LOCAL_PEERCRED fallback rationale — same security boundary
  as socket file permissions (0600), does not widen attack surface.
  Long-lived connections still get full descendant check via LOCAL_PEERPID.
This commit is contained in:
Lawrence Chen 2026-02-19 01:04:47 -08:00 committed by GitHub
parent 3193e602d4
commit 9642bb59fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 516 additions and 158 deletions

View file

@ -89,6 +89,7 @@ final class Workspace: Identifiable, ObservableObject {
@Published var gitBranch: SidebarGitBranchState?
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:]
var focusedSurfaceId: UUID? { focusedPanelId }
var surfaceDirectories: [UUID: String] {
@ -330,6 +331,7 @@ final class Workspace: Identifiable, ObservableObject {
panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) }
panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
}
@ -1292,6 +1294,8 @@ extension Workspace: BonsplitDelegate {
panelDirectories.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
// Keep the workspace invariant: always retain at least one real panel.
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.