Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
36e5992
research(41): add the tier-1 ten-tab footprint script
danneu Sep 1, 2026
80fb806
research(41): T2 vmmap capture confirms H1
danneu Sep 1, 2026
875b307
feat(engine): let a swapchain report the surfaces it holds
danneu Sep 1, 2026
19dc5cc
feat(cli): add `danterm surfaces`, the live presentation-surface census
danneu Sep 1, 2026
437e09b
research(41): T1 lands, and the tier-1 script reads the app's attribu…
danneu Sep 1, 2026
4851818
feat(app): trace a pane's presentation events for reveal latency
danneu Sep 1, 2026
2c544f8
feat(app): trace the swapchain a pane throws away
danneu Sep 1, 2026
951b439
research(41): T3 measures the switch, and it presents nothing
danneu Sep 1, 2026
9e63833
research(41): T6 prices the volatile hidden chain, and it is cheap
danneu Sep 1, 2026
54a3f10
perf(engine): stop clearing a fresh frame surface
danneu Sep 1, 2026
ba34f2c
research(41): T5 confirms H2 and the clear comes out
danneu Sep 1, 2026
b7a194f
research(41): merge T5 into the T6 series (rows renumbered S7, S8)
danneu Sep 1, 2026
ed59e1f
research(41): T7 closes the direction gate, D2 picks volatile-when-free
danneu Sep 1, 2026
8ccdec4
feat(pane): a hidden pane owns no pixels (research/41 T8, commit 1)
danneu Sep 2, 2026
471e8c0
feat(pane): a hidden pane's free buffers go volatile (research/41 T8,…
danneu Sep 2, 2026
a0c5c7d
research(41): T8 lands, F9 reads it -- 56.9 MB idle, and the fast pat…
danneu Sep 2, 2026
3c5dfef
Revert "feat(pane): a hidden pane's free buffers go volatile (researc…
danneu Sep 2, 2026
296284d
research(41): D5 takes the fast path out and ships the ideal alone
danneu Sep 2, 2026
76920c1
fix(research/41): the latency shim goes through the adapter, not a mo…
danneu Sep 2, 2026
0dc6274
research(41): T2b and T9 land -- 644 MB to 56.5 MB, against an 82 KB …
danneu Sep 2, 2026
fb5dd8f
research(41): T10 censuses the remainder -- two classes hold 80.7% of…
danneu Sep 2, 2026
1563cd7
research(41): T4 censuses the scrollback line -- H3 confirmed at 13.8…
danneu Sep 2, 2026
598c09a
research(41): merge T4 into the T10 series (rows renumbered S22, S23)
danneu Sep 2, 2026
94f1283
feat(cli): surfaces moves under a new debug namespace (research/41)
danneu Sep 2, 2026
1776ab7
research(41): review corrections -- H5 decided, D6/D7 recorded, stale…
danneu Sep 2, 2026
4956122
fix(pane): only model visibility releases a pane's pixels (research/4…
danneu Sep 2, 2026
dd788cf
fix(cli): debug surfaces contract names the unmeasured entry; researc…
danneu Sep 2, 2026
8ff0b67
Merge branch 'worktree-agent-af5c6804a70912c1b' into research41
danneu Sep 2, 2026
07896c7
Merge branch 'worktree-agent-ade4dfcc2b48adcb2' into research41
danneu Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ __pycache__/
/lib/DanTermClient/.build-ios-gate
/lib/DanTermProtocol/.build-ios-gate
/lib/TerminalCore/.build-ios-gate

# Staging directories for research 41 tier-1 readings: gate and done files
# for a slot that is already gone by the time the script exits.
/.run
5 changes: 5 additions & 0 deletions app-tests/AppRuntimeCommandTestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ final class RecordingTerminalSession: NSView, TerminalSession {
var viewportCells: TerminalSessionViewportCells?
var fullHistoryText: String?
var rowStructure: [TerminalSessionRowStructure]?
/// The census this session reports. `nil` is the unmeasured answer -- a session
/// with no presentation of its own -- and is the default, because that is what a
/// double without buffers honestly holds.
var surfaceCensus: TerminalSessionSurfaceCensus?
var paneTapeOpenings: [(PaneTapeCaptureMode, PaneTapeStartPosition, PaneTapeSyncPolicy)] = []
/// The opening this session hands a tape reader. `nil` reports "no terminal to read",
/// so a test that wants a live follow stream on this pane assigns one.
Expand Down Expand Up @@ -194,6 +198,7 @@ final class RecordingTerminalSession: NSView, TerminalSession {
func endSearch() { endSearchCount += 1 }
func readViewportText() -> String? { viewportText }
func readViewportCells() -> TerminalSessionViewportCells? { viewportCells }
func readSurfaceCensus() -> TerminalSessionSurfaceCensus? { surfaceCensus }
func readRowStructure() -> [TerminalSessionRowStructure]? { rowStructure }
func readFullHistoryText() -> String? { fullHistoryText }
func readPrimaryHistoryText() -> String? { primaryHistoryText }
Expand Down
95 changes: 95 additions & 0 deletions app-tests/AppRuntimeIpcCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,101 @@ struct AppRuntimeIpcCommandTests {
]))
}

@Test("the surface census sums measured panes and names unmeasured ones")
func surfaceCensusSeparatesUnmeasuredFromZero() async throws {
// Intent: the census walks every installed pane, sums only the panes whose
// session answered, and lists the ones that could not be measured instead
// of counting them as panes holding nothing.
// Why it exists: research/41 D1 admits an in-app attribution only when zero
// stays distinguishable from unmeasured; a walk that summed a nil as a
// zero would report a smaller footprint than the process holds.
// Scenario: three installed panes -- one reporting a hidden three-buffer
// rotation, one visible pane that answered but holds no buffers, and one
// with no presentation to measure.
let ports = RecordingAppRuntimePorts()
let runtime = makeCommandTestRuntime(ports)
defer { runtime.shutdown() }
let measuredId = PaneId(rawValue: UUID(uuidString: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!)
let unmeasuredId = PaneId(rawValue: UUID(uuidString: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")!)
let measured = RecordingTerminalSession()
measured.surfaceCensus = TerminalSessionSurfaceCensus(
isVisible: false,
swapchain: TerminalSessionSurfaceCensus.Swapchain(
storeCount: 3,
bytes: 60_710_400,
pixelWidth: 2720,
pixelHeight: 1860
),
displayedStoreOutsideSwapchainBytes: 20_250_624
)
let unmeasured = RecordingTerminalSession()
let emptyId = PaneId(rawValue: UUID(uuidString: "cccccccc-cccc-4ccc-8ccc-cccccccccccc")!)
let empty = RecordingTerminalSession()
empty.surfaceCensus = TerminalSessionSurfaceCensus(
isVisible: true,
swapchain: nil,
displayedStoreOutsideSwapchainBytes: nil
)
runtime.installTerminalSession(measured, paneId: measuredId)
runtime.installTerminalSession(unmeasured, paneId: unmeasuredId)
runtime.installTerminalSession(empty, paneId: emptyId)
let fixture = try CommandIpcConnectionFixture()
defer {
fixture.connection.close()
fixture.closePeer()
}
let reqId = UUID()
register(fixture, requestId: reqId, rpcId: .number(1), runtime: runtime)

runtime.perform(.readDebugSurfaces(reqId: reqId))

let envelope = try await fixture.readResponseAsync()
let result = try #require(envelope.result)
#expect(result["panes"] == .object([
"total": .number(3),
"visible": .number(1),
"hidden": .number(1),
"measured": .number(2),
"unmeasured": .array([.string(unmeasuredId.rawValue.uuidString)]),
]))
#expect(result["swapchains"] == .object([
"count": .number(1),
"stores": .number(3),
"bytes": .number(60_710_400),
]))
#expect(result["displayedOutsideSwapchain"] == .object([
"count": .number(1),
"bytes": .number(20_250_624),
]))
#expect(result["surfaces"] == .object([
"count": .number(4),
"bytes": .number(80_961_024),
]))
#expect(result["perPane"] == .array([
.object([
"paneId": .string(measuredId.rawValue.uuidString),
"visible": .bool(false),
"swapchain": .object([
"stores": .number(3),
"bytes": .number(60_710_400),
"pixelWidth": .number(2720),
"pixelHeight": .number(1860),
]),
"displayedOutsideSwapchainBytes": .number(20_250_624),
]),
.object([
"paneId": .string(unmeasuredId.rawValue.uuidString),
"swapchain": .string("unmeasured"),
]),
.object([
"paneId": .string(emptyId.rawValue.uuidString),
"visible": .bool(true),
"swapchain": .null,
"displayedOutsideSwapchainBytes": .null,
]),
]))
}

@Test("pane text and row reads preserve terminal results")
func paneReadsWriteSessionResults() async throws {
let ports = RecordingAppRuntimePorts()
Expand Down
1 change: 1 addition & 0 deletions app-tests/PaneHostHeadlessTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ private final class HeadlessPaneHostSession: NSView, TerminalSession {
func endSearch() {}
func readViewportText() -> String? { nil }
func readViewportCells() -> TerminalSessionViewportCells? { nil }
func readSurfaceCensus() -> TerminalSessionSurfaceCensus? { nil }
func readRowStructure() -> [TerminalSessionRowStructure]? { nil }
func readFullHistoryText() -> String? { nil }
func readPrimaryHistoryText() -> String? { nil }
Expand Down
98 changes: 98 additions & 0 deletions app-tests/TerminalPresentationEventSamplerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// What the presentation-event trace must guarantee to a reader that computes a
// latency from it: nothing is written unless the environment asked for a trace,
// every event is one line, and the line carries the monotonic timestamp it was
// given rather than one the sampler chose later. Where the view records each
// event is the view's subject, not this file's.
import Foundation
import Testing

@testable import DanTerm

@MainActor
struct TerminalPresentationEventSamplerTests {
@Test("no sampler exists unless the environment names a log file")
func absentVariableMakesNoSampler() {
#expect(TerminalPresentationEventSampler.make(environment: [:]) == nil)
#expect(
TerminalPresentationEventSampler.make(
environment: [TerminalPresentationEventSampler.environmentVariable: ""]
) == nil
)
}

// Intent: one recorded event becomes exactly one JSON line naming the event
// and the timestamp it was recorded at.
// Why it exists: the reader pairs a `reveal` with the `attach` that follows
// it and subtracts the two timestamps, so a dropped line, a merged line, or
// a re-clocked timestamp turns into a wrong latency rather than an error.
// Scenario: a pane is created, revealed, presents a frame, and is hidden.
@Test("each event is one line carrying its own name and timestamp")
func eachEventIsOneTimestampedLine() throws {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("presentation-events-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: directory) }
let log = directory.appendingPathComponent("trace.jsonl")

let sampler = try #require(
TerminalPresentationEventSampler.make(
environment: [
TerminalPresentationEventSampler.environmentVariable: log.path,
]
)
)
sampler.record(.create, at: 1_000)
sampler.record(.reveal, at: 2_500)
sampler.record(.attach, at: 9_750)
sampler.record(.hide, at: 20_000)
sampler.close()

let lines = try String(contentsOf: log, encoding: .utf8)
.split(separator: "\n")
.map { line -> [String: Any] in
let object = try JSONSerialization.jsonObject(with: Data(line.utf8))
return object as? [String: Any] ?? [:]
}
#expect(lines.count == 4)
#expect(lines.map { $0["event"] as? String } == ["create", "reveal", "attach", "hide"])
#expect(lines.map { $0["uptimeNanoseconds"] as? UInt64 } == [1_000, 2_500, 9_750, 20_000])
// One pane's whole trace carries one pane index, so a reader can split a
// multi-pane log by it without any other key.
#expect(Set(lines.compactMap { $0["pane"] as? Int }).count == 1)
}

// Intent: two panes tracing into one file stay separable.
// Why it exists: the ten-tab staging traces ten panes into a single log, and
// a reveal has to be paired with the attach of the same pane; sharing an
// index would pair one pane's reveal with another pane's frame.
// Scenario: two panes exist at once and both record an event.
@Test("two samplers writing one file carry different pane indexes")
func panesAreSeparable() throws {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("presentation-events-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: directory) }
let log = directory.appendingPathComponent("trace.jsonl")
let environment = [TerminalPresentationEventSampler.environmentVariable: log.path]

let first = try #require(TerminalPresentationEventSampler.make(environment: environment))
let second = try #require(TerminalPresentationEventSampler.make(environment: environment))
first.record(.reveal, at: 1)
second.record(.reveal, at: 2)
first.close()
second.close()

let indexes = try String(contentsOf: log, encoding: .utf8)
.split(separator: "\n")
.map { line -> Int in
let object = try JSONSerialization.jsonObject(with: Data(line.utf8))
return (object as? [String: Any])?["pane"] as? Int ?? -1
}
try #require(indexes.count == 2)
#expect(indexes[0] != indexes[1])
}
}
40 changes: 29 additions & 11 deletions app/AppPresentationLifecycle.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// AppKit presentation lifecycle adapters: system sleep and window occlusion
// become independent inputs at the stable terminal-session boundary.
// become independent inputs at the stable terminal-session boundary. Both feed
// the rendering gate only; pane visibility, which decides pixel ownership, is
// derived from the model alone.
import Cocoa

/// Owns the workspace notification tokens that suspend and resume terminal rendering.
Expand All @@ -21,13 +23,13 @@ final class WorkspaceLifecycleObserver {
NSWorkspace.willSleepNotification,
center: notificationCenter
) { [weak self] in
self?.runtime?.setRenderingAvailable(false)
self?.runtime?.setSystemRenderingAvailable(false)
},
observeOnMain(
NSWorkspace.didWakeNotification,
center: notificationCenter
) { [weak self] in
self?.runtime?.setRenderingAvailable(true)
self?.runtime?.setSystemRenderingAvailable(true)
},
]
}
Expand All @@ -54,21 +56,37 @@ final class WorkspaceLifecycleObserver {

@MainActor
extension AppRuntime {
/// Pushes system rendering availability to every live terminal session.
func setRenderingAvailable(_ available: Bool) {
/// Records whether the system is awake, then republishes the rendering gate.
func setSystemRenderingAvailable(_ available: Bool) {
guard schedulingLifecycle.isActive else { return }
guard systemRenderingAvailable != available else { return }
systemRenderingAvailable = available
syncRenderingAvailable()
}

/// Pushes the rendering gate -- the system is awake and the window is not
/// occluded -- to every live terminal session.
///
/// Both inputs only suspend rendering. Neither one may reach `setVisible`,
/// because a hidden pane gives up its layer contents and its buffers, and the
/// window of an occluded app is still composited live by Mission Control and
/// App Expose (research/41 T8 review).
func syncRenderingAvailable() {
guard schedulingLifecycle.isActive else { return }
let windowVisible = window?.occlusionState.contains(.visible) ?? true
let available = systemRenderingAvailable && windowVisible
guard renderingAvailable != available else { return }
renderingAvailable = available
for host in paneHosts.values {
host.session.setRenderingAvailable(available)
}
}

/// Pushes effective model and window visibility to live terminal sessions.
/// Pushes model-derived pane visibility -- which decides pixel ownership -- to
/// live terminal sessions.
func syncPaneVisibility() {
guard schedulingLifecycle.isActive else { return }
let windowVisible = window?.occlusionState.contains(.visible) ?? true
let desired = effectivePaneVisibility(in: model, windowVisible: windowVisible)
let desired = effectivePaneVisibility(in: model)

// Each record remembers what its own session was last told, so a pane installed
// under a reused pane id starts from "nothing pushed yet" and there is no stale
Expand Down Expand Up @@ -104,13 +122,13 @@ extension AppDelegate {
workspaceLifecycleObserver = nil
}

// NSWindowDelegate: window occlusion changes alter every pane's effective
// renderer visibility.
// NSWindowDelegate: window occlusion suspends and resumes rendering. It never
// touches pane visibility, so no pane releases its pixels behind a cover.
func windowDidChangeOcclusionState(_ notification: Notification) {
guard notification.object is NSWindow else { return }
#if DANTERM_TERMINAL_BENCHMARK
benchmarkStateRecorder?.windowDidChangeOcclusionState()
#endif
runtime?.syncPaneVisibility()
runtime?.syncRenderingAvailable()
}
}
Loading