Merge nucleic/eager-glass-civet-f3ph into dev

This commit is contained in:
2026-07-29 04:03:22 -07:00
parent 89f0b8b1c4
commit ef60a9ab7a
20 changed files with 1561 additions and 315 deletions
+48 -15
View File
@@ -1590,8 +1590,8 @@ public final class AppStore: ConflictArbiter {
// shared listener so Build/Run spawns under nash can report. Everything stays lazy the // shared listener so Build/Run spawns under nash can report. Everything stays lazy the
// listener binds no port until a spawn actually registers. // listener binds no port until a spawn actually registers.
Task { Task {
await HostShellEventListener.shared.install { [weak self] origin, call in await HostShellEventListener.shared.install { [weak self] origin, calls in
await self?.observeAppScopedShellEvent(origin: origin, call: call) await self?.observeAppScopedShellEvents(origin: origin, calls: calls)
} }
} }
defaultsChangeObserver = NotificationObserverToken(NotificationCenter.default.addObserver( defaultsChangeObserver = NotificationObserverToken(NotificationCenter.default.addObserver(
@@ -5793,19 +5793,43 @@ public final class AppStore: ConflictArbiter {
/// ///
/// While the shims and nash coexist, exec events dedupe naturally by feed. Purely observational /// While the shims and nash coexist, exec events dedupe naturally by feed. Purely observational
/// for the non-git kinds. /// for the non-git kinds.
public func observeShellEvent(sessionID: SessionID, call: MCPApprovalServer.ShellReportCall) async { ///
/// Delivered one nash batch at a time (docs/NASH_STREAM_PERF_PLAN.md §P1): the viewer's feed
/// takes the whole batch in a single append, and only then is each event routed onward. Order
/// within the batch is nash's own, which is what keeps an `exec-start` ahead of the `exec` that
/// retires it.
public func observeShellEvents(
sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]
) async {
// The Nash Viewer's feed, recorded first and for *every* kind the routing below is lossy // The Nash Viewer's feed, recorded first and for *every* kind the routing below is lossy
// by design (it forwards exec onto the git/command feeds and reduces the rest to a log // by design (it forwards exec onto the git/command feeds and reduces the rest to a log
// line), whereas the viewer's promise is the complete emission stream for this session. // line), whereas the viewer's promise is the complete emission stream for this session.
nashEvents.record(sessionID: sessionID, call: call, at: now()) nashEvents.record(sessionID: sessionID, calls: calls, at: now())
for call in calls {
await routeShellEvent(sessionID: sessionID, call: call)
}
}
/// The per-event half of ``observeShellEvents(sessionID:calls:)``: everything downstream of the
/// viewer's feed, for one event. Split out so the feed can take the batch whole.
///
/// The `CMDTRACE` lines here are at `.debug` rather than `.notice`
/// (docs/NASH_STREAM_PERF_PLAN.md §P1.3): nash emits one per pipe link, per redirect and per
/// `cd`, so a build storm writes thousands of them a minute per agent a volume at which
/// os_log's public-interpolation formatting is itself measurable, and at which the trail is
/// unreadable anyway. The durable feed (`nash_event`) is the record that survives; these are the
/// live tail, and `log stream --level debug` still shows them. `.policy` keeps `.error`: it is
/// rare and never routine.
private func routeShellEvent(
sessionID: SessionID, call: MCPApprovalServer.ShellReportCall
) async {
switch call.kind { switch call.kind {
case .execStart(let argv, let cwd, _, _): case .execStart(let argv, let cwd, _, _):
// Announcement only (docs/NASH.md §5.1) the command hasn't returned, so there is no // Announcement only (docs/NASH.md §5.1) the command hasn't returned, so there is no
// outcome to route: no git/gh op to record, nothing to classify for the command feed, // outcome to route: no git/gh op to record, nothing to classify for the command feed,
// no residue to capture. The Nash Viewer's in-flight row above is the whole point of // no residue to capture. The Nash Viewer's in-flight row above is the whole point of
// it; here it is a trace line, and the `exec` that follows does the real work. // it; here it is a trace line, and the `exec` that follows does the real work.
commandLog.notice( commandLog.debug(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=exec-start argv=\(argv.joined(separator: " "), privacy: .public) cwd=\(cwd, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=exec-start argv=\(argv.joined(separator: " "), privacy: .public) cwd=\(cwd, privacy: .public)")
case .exec(let argv, let cwd, let exitCode, let durationMs, _): case .exec(let argv, let cwd, let exitCode, let durationMs, _):
guard let head = argv.first else { return } guard let head = argv.first else { return }
@@ -5824,16 +5848,16 @@ public final class AppStore: ConflictArbiter {
durationMs: durationMs, stdout: "", stderr: "", truncated: false, durationMs: durationMs, stdout: "", stderr: "", truncated: false,
source: .nash, sessionID: sessionID.rawValue)) source: .nash, sessionID: sessionID.rawValue))
case .cd(let from, let to): case .cd(let from, let to):
commandLog.notice( commandLog.debug(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=cd from=\(from, privacy: .public) to=\(to, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=cd from=\(from, privacy: .public) to=\(to, privacy: .public)")
case .export(let op, let names): case .export(let op, let names):
// Names only values are never reported by nash (docs/NASH.md §5.4). // Names only values are never reported by nash (docs/NASH.md §5.4).
commandLog.notice( commandLog.debug(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=\(op, privacy: .public) names=\(names.joined(separator: ","), privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=\(op, privacy: .public) names=\(names.joined(separator: ","), privacy: .public)")
case .redirect(let op, _, let target, let bytes, _, _, _): case .redirect(let op, _, let target, let bytes, _, _, _):
// Data-flow observation (docs/NASH.md §5.2). Feed rows that render the preview are a // Data-flow observation (docs/NASH.md §5.2). Feed rows that render the preview are a
// follow-up (§9.5); for now the operator/target/size is logged. // follow-up (§9.5); for now the operator/target/size is logged.
commandLog.notice( commandLog.debug(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=redirect op=\(op, privacy: .public) target=\(target ?? "-", privacy: .public) bytes=\(bytes, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=redirect op=\(op, privacy: .public) target=\(target ?? "-", privacy: .public) bytes=\(bytes, privacy: .public)")
// A write redirection is a file edit the agent made with raw shell. The approval gate // A write redirection is a file edit the agent made with raw shell. The approval gate
// already locked the targets it could name statically; this is the ground-truth half // already locked the targets it could name statically; this is the ground-truth half
@@ -5843,15 +5867,19 @@ public final class AppStore: ConflictArbiter {
await claimShellWrite(sessionID: sessionID, path: target) await claimShellWrite(sessionID: sessionID, path: target)
} }
case .cmdsub(let bytes, _, _, _): case .cmdsub(let bytes, _, _, _):
commandLog.notice( commandLog.debug(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=cmdsub bytes=\(bytes, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=cmdsub bytes=\(bytes, privacy: .public)")
case .pipe(_, let fromIndex, let toIndex, _, _, let bytes, _, _, _): case .pipe(_, let fromIndex, let toIndex, _, _, let bytes, _, _, _):
commandLog.notice( commandLog.debug(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=pipe link=\(fromIndex, privacy: .public)->\(toIndex, privacy: .public) bytes=\(bytes, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=pipe link=\(fromIndex, privacy: .public)->\(toIndex, privacy: .public) bytes=\(bytes, privacy: .public)")
case .fallback(let reason, _): case .fallback(let reason, _):
// Rare and structural nash gave up on an input and re-execed bash. Stays at `.notice`
// for the same reason `.policy` stays at `.error`: it is never part of the flood.
commandLog.notice( commandLog.notice(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=fallback reason=\(reason, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=fallback reason=\(reason, privacy: .public)")
case .dropped(let count): case .dropped(let count):
// Back-pressure: the stream lost events. Also rare, and worth seeing without a debug
// filter, since it bounds how much the rest of this trail can be trusted.
commandLog.notice( commandLog.notice(
"CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=dropped count=\(count, privacy: .public)") "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=dropped count=\(count, privacy: .public)")
case .policy(let lever, let value, let action, _): case .policy(let lever, let value, let action, _):
@@ -5862,27 +5890,32 @@ public final class AppStore: ConflictArbiter {
} }
} }
/// The app-scoped twin of ``observeShellEvent(sessionID:call:)`` (docs/NASH.md §7.7.3): shell /// The app-scoped twin of ``observeShellEvents(sessionID:calls:)`` (docs/NASH.md §7.7.3): shell
/// events from host spawns with no session Build/Run today delivered through /// events from host spawns with no session Build/Run today delivered through
/// ``HostShellEventListener`` and attributed by a **synthetic origin id** /// ``HostShellEventListener`` and attributed by a **synthetic origin id**
/// (`buildrun:<projectID>`) rather than a fake session. They join the Nash Viewer's feed under /// (`buildrun:<projectID>`) rather than a fake session. They join the Nash Viewer's feed under
/// that id; none of the session-only semantics apply (git/gh convergence feeds a session's /// that id; none of the session-only semantics apply (git/gh convergence feeds a session's
/// locks/autoship, residue capture targets a session's worktree neither exists here), so the /// locks/autoship, residue capture targets a session's worktree neither exists here), so the
/// rest is the CMDTRACE log, same as the session path's non-exec kinds. /// rest is the CMDTRACE log, same as the session path's non-exec kinds.
public func observeAppScopedShellEvent( ///
origin: String, call: MCPApprovalServer.ShellReportCall /// Batch-shaped for the same reason as the session path (docs/NASH_STREAM_PERF_PLAN.md §P1): a
/// Build & Run posts one body per 200 events, and the feed takes it in one append.
public func observeAppScopedShellEvents(
origin: String, calls: [MCPApprovalServer.ShellReportCall]
) async { ) async {
nashEvents.record( nashEvents.record(
sessionID: SessionID(rawValue: origin), call: call, sessionID: SessionID(rawValue: origin), calls: calls,
environment: .app( environment: .app(
origin: origin, label: origin.hasPrefix("buildrun:") ? "Build & Run" : "App"), origin: origin, label: origin.hasPrefix("buildrun:") ? "Build & Run" : "App"),
at: now()) at: now())
for call in calls {
if case .exec(let argv, let cwd, let exitCode, _, _) = call.kind { if case .exec(let argv, let cwd, let exitCode, _, _) = call.kind {
commandLog.notice( commandLog.debug(
"CMDTRACE origin=\(origin, privacy: .public) src=nash kind=exec argv=\(argv.joined(separator: " "), privacy: .public) cwd=\(cwd, privacy: .public) exit=\(exitCode, privacy: .public)" "CMDTRACE origin=\(origin, privacy: .public) src=nash kind=exec argv=\(argv.joined(separator: " "), privacy: .public) cwd=\(cwd, privacy: .public) exit=\(exitCode, privacy: .public)"
) )
} }
} }
}
/// Whether a nash redirection operator wrote to its target. `<` and `<<`/`<<<` only read, and /// Whether a nash redirection operator wrote to its target. `<` and `<<`/`<<<` only read, and
/// `<>` is a read-write open the shell uses for coprocess-style plumbing rather than authoring. /// `<>` is a read-write open the shell uses for coprocess-style plumbing rather than authoring.
@@ -923,14 +923,14 @@ public actor ClaudeCodeBackend: AgentBackend {
// The nash agent shell's exec gate reports to `/shell-event` under the same // The nash agent shell's exec gate reports to `/shell-event` under the same
// gate/token (docs/NASH.md §6) ground-truth exec/cd/export/fallback events for // gate/token (docs/NASH.md §6) ground-truth exec/cd/export/fallback events for
// the feeds. Same fire-and-forget contract. // the feeds. Same fire-and-forget contract.
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.handleShellReport(call) } Task { await self?.handleShellReport(calls) }
} }
} else if run.container != nil { } else if run.container != nil {
// Non-Control sandbox runs do not install the git/gh/command shims, but their // Non-Control sandbox runs do not install the git/gh/command shims, but their
// default `linux_container` children can still reuse this control socket for nash. // default `linux_container` children can still reuse this control socket for nash.
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.handleShellReport(call) } Task { await self?.handleShellReport(calls) }
} }
} }
let mcpConfig = server.mcpConfigJSON(host: controlHost, port: port, token: token) let mcpConfig = server.mcpConfigJSON(host: controlHost, port: port, token: token)
@@ -2850,10 +2850,8 @@ public actor ClaudeCodeBackend: AgentBackend {
guard let manager = macVMManager, let sessionID, let conflictCoordinator else { return } guard let manager = macVMManager, let sessionID, let conflictCoordinator else { return }
let batches = await manager.drainShellEvents(name: vmName) let batches = await manager.drainShellEvents(name: vmName)
for batch in batches { for batch in batches {
for call in MCPApprovalServer.parseShellBatch(batch) { let calls = MCPApprovalServer.parseShellBatch(batch).map { $0.attributed(to: environment) }
await conflictCoordinator.observeShellEvent( await conflictCoordinator.observeShellEvents(sessionID: sessionID, calls: calls)
sessionID: sessionID, call: call.attributed(to: environment))
}
} }
} }
@@ -3229,8 +3227,8 @@ public actor ClaudeCodeBackend: AgentBackend {
if spec.controlSocketHostPath != nil, let server = runServer, if spec.controlSocketHostPath != nil, let server = runServer,
let token = serverToken let token = serverToken
{ {
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.handleShellReport(call) } Task { await self?.handleShellReport(calls) }
} }
env = CommandInterceptor.hookEnv( env = CommandInterceptor.hookEnv(
host: "127.0.0.1", port: ContainerSpec.controlBridgePort, host: "127.0.0.1", port: ContainerSpec.controlBridgePort,
@@ -3820,9 +3818,9 @@ public actor ClaudeCodeBackend: AgentBackend {
/// `ConflictCoordinator` the same seam as `handleCommandReport`. `AppStore` owns the /// `ConflictCoordinator` the same seam as `handleCommandReport`. `AppStore` owns the
/// semantics: converge exec events onto the command/git/gh feeds and log shell state changes /// semantics: converge exec events onto the command/git/gh feeds and log shell state changes
/// (docs/NASH.md §9). Best-effort: dropped when there's no coordinator (pipeline tests). /// (docs/NASH.md §9). Best-effort: dropped when there's no coordinator (pipeline tests).
private func handleShellReport(_ call: MCPApprovalServer.ShellReportCall) async { private func handleShellReport(_ calls: [MCPApprovalServer.ShellReportCall]) async {
guard let sessionID, let conflictCoordinator else { return } guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeShellEvent(sessionID: sessionID, call: call) await conflictCoordinator.observeShellEvents(sessionID: sessionID, calls: calls)
} }
/// NDJSON user-message envelope (ADAPTERS §1.3 pinned by the M0 live capture). /// NDJSON user-message envelope (ADAPTERS §1.3 pinned by the M0 live capture).
@@ -788,7 +788,13 @@ public actor MCPApprovalServer {
/// As ``GitReportHandler``, for the non-git command interceptor (`POST /command-event`). /// As ``GitReportHandler``, for the non-git command interceptor (`POST /command-event`).
public typealias CommandReportHandler = @Sendable (CommandReportCall) -> Void public typealias CommandReportHandler = @Sendable (CommandReportCall) -> Void
/// As ``GitReportHandler``, for the nash agent shell's exec gate (`POST /shell-event`). /// As ``GitReportHandler``, for the nash agent shell's exec gate (`POST /shell-event`).
public typealias ShellReportHandler = @Sendable (ShellReportCall) -> Void ///
/// Takes the **whole batch** rather than one call at a time (docs/NASH_STREAM_PERF_PLAN.md §P1):
/// nash posts up to 200 events per body, and a build storm posts thousands per minute across
/// every concurrent agent. Delivering per event made each one its own hop to the main actor
/// and, because those hops were unstructured `Task`s, let a batch's rows land out of the order
/// nash emitted them (an `exec` overtaking its own `exec-start`). One call per batch fixes both.
public typealias ShellReportHandler = @Sendable ([ShellReportCall]) -> Void
/// The dedicated POST path the `git` interceptor shim reports to bearer-token gated like /// The dedicated POST path the `git` interceptor shim reports to bearer-token gated like
/// the MCP route, but a plain JSON POST rather than JSON-RPC. /// the MCP route, but a plain JSON POST rather than JSON-RPC.
@@ -3386,9 +3392,11 @@ public actor MCPApprovalServer {
/// Handle a `POST /shell-event` from the nash agent shell (docs/NASH.md §6.1): bearer-token /// Handle a `POST /shell-event` from the nash agent shell (docs/NASH.md §6.1): bearer-token
/// gated like `/git-event`. Body is a `shell-batch` (`{type, source:"nash", sessionId, shellId, /// gated like `/git-event`. Body is a `shell-batch` (`{type, source:"nash", sessionId, shellId,
/// events:[]}`), fanned out to one ``ShellReportCall`` per event. Fire-and-forget: the handler /// events:[]}`), decoded to one ``ShellReportCall`` per event and delivered to the handler as
/// returns immediately. Always 202s a known token (even on a malformed body) so nash never /// **one batch** the ingest cost is per delivery, not per event, so a 200-event body must not
/// retries or stalls; 401s an unknown one (which nash treats as "spool instead"). /// become 200 hops (docs/NASH_STREAM_PERF_PLAN.md §P1). Fire-and-forget: the handler returns
/// immediately. Always 202s a known token (even on a malformed body) so nash never retries or
/// stalls; 401s an unknown one (which nash treats as "spool instead").
private func handleShellEvent(_ request: HTTPRequest) -> HTTPResponse { private func handleShellEvent(_ request: HTTPRequest) -> HTTPResponse {
let authorization = request.headers["authorization"] ?? "" let authorization = request.headers["authorization"] ?? ""
let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : "" let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
@@ -3398,7 +3406,8 @@ public actor MCPApprovalServer {
body: Data(#"{"error":"invalid bearer token"}"#.utf8)) body: Data(#"{"error":"invalid bearer token"}"#.utf8))
} }
if let message = try? JSONValue(parsing: request.body) { if let message = try? JSONValue(parsing: request.body) {
for call in Self.parseShellBatch(message) { handler(call) } let calls = Self.parseShellBatch(message)
if !calls.isEmpty { handler(calls) }
} }
return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data()) return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data())
} }
@@ -280,8 +280,8 @@ public actor CodexAppServerBackend: AgentBackend {
await server.registerCommandReport(token: token) { [weak self] call in await server.registerCommandReport(token: token) { [weak self] call in
Task { await self?.handleCommandReport(call) } Task { await self?.handleCommandReport(call) }
} }
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.handleShellReport(call) } Task { await self?.handleShellReport(calls) }
} }
await platformToolRuntime.configurePlatformToolRuntime(for: run) { await platformToolRuntime.configurePlatformToolRuntime(for: run) {
[weak self] kind, nativeType in [weak self] kind, nativeType in
@@ -554,9 +554,9 @@ public actor CodexAppServerBackend: AgentBackend {
} }
/// Forward a nash agent-shell event (`/shell-event`) for the feeds (docs/NASH.md §9). /// Forward a nash agent-shell event (`/shell-event`) for the feeds (docs/NASH.md §9).
private func handleShellReport(_ call: MCPApprovalServer.ShellReportCall) async { private func handleShellReport(_ calls: [MCPApprovalServer.ShellReportCall]) async {
guard let sessionID, let conflictCoordinator else { return } guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeShellEvent(sessionID: sessionID, call: call) await conflictCoordinator.observeShellEvents(sessionID: sessionID, calls: calls)
} }
private func interruptCurrentTurn() async { private func interruptCurrentTurn() async {
@@ -279,8 +279,8 @@ public actor CodexExecBackend: AgentBackend {
await server.registerCommandReport(token: token) { [weak self] call in await server.registerCommandReport(token: token) { [weak self] call in
Task { await self?.handleCommandReport(call) } Task { await self?.handleCommandReport(call) }
} }
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.handleShellReport(call) } Task { await self?.handleShellReport(calls) }
} }
env.merge( env.merge(
CommandInterceptor.hookEnv( CommandInterceptor.hookEnv(
@@ -433,9 +433,9 @@ public actor CodexExecBackend: AgentBackend {
await conflictCoordinator.observeCommand(sessionID: sessionID, call: call) await conflictCoordinator.observeCommand(sessionID: sessionID, call: call)
} }
private func handleShellReport(_ call: MCPApprovalServer.ShellReportCall) async { private func handleShellReport(_ calls: [MCPApprovalServer.ShellReportCall]) async {
guard let sessionID, let conflictCoordinator else { return } guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeShellEvent(sessionID: sessionID, call: call) await conflictCoordinator.observeShellEvents(sessionID: sessionID, calls: calls)
} }
} }
+21 -4
View File
@@ -218,10 +218,16 @@ public protocol ConflictArbiter: AnyObject, Sendable {
/// shim or bash-tracer report). The arbiter classifies it and records it into the command /// shim or bash-tracer report). The arbiter classifies it and records it into the command
/// activity feed + structured log. No-op outside Nucleic Control. /// activity feed + structured log. No-op outside Nucleic Control.
@MainActor func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async @MainActor func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async
/// A ground-truth event the nash agent shell observed for `sessionID` (an exec-gate report; the /// One nash batch of ground-truth events observed for `sessionID` (the `POST /shell-event`
/// `POST /shell-event` route). The arbiter converges exec events onto the command/git/gh feeds /// route, in the order nash emitted them). The arbiter converges exec events onto the
/// (docs/NASH.md §9) and records shell state changes. No-op outside Nucleic Control. /// command/git/gh feeds (docs/NASH.md §9) and records shell state changes. No-op outside
@MainActor func observeShellEvent(sessionID: SessionID, call: MCPApprovalServer.ShellReportCall) async /// Nucleic Control.
///
/// Delivered a batch at a time because that is nash's own unit up to 200 events per post
/// and the arbiter's per-delivery costs (the actor hop, the viewer's ring append and its
/// observation invalidation) are what a build storm multiplies (docs/NASH_STREAM_PERF_PLAN.md
/// §P1). ``observeShellEvent(sessionID:call:)`` remains for single-event callers.
@MainActor func observeShellEvents(sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]) async
/// Try to claim a host-run slot for `sessionID` before a `host_exec` command runs on the shared /// Try to claim a host-run slot for `sessionID` before a `host_exec` command runs on the shared
/// host (HOST_EXEC §concurrency). Registers the command as running and returns `.clear` when no /// host (HOST_EXEC §concurrency). Registers the command as running and returns `.clear` when no
/// *similar* command is already running in another session (the fuzzy /// *similar* command is already running in another session (the fuzzy
@@ -247,6 +253,17 @@ public protocol ConflictArbiter: AnyObject, Sendable {
@MainActor func dequeueHostCommandWait(sessionID: SessionID) async @MainActor func dequeueHostCommandWait(sessionID: SessionID) async
} }
extension ConflictArbiter {
/// One nash event on its own the same delivery as ``observeShellEvents(sessionID:calls:)``
/// with a batch of one. For callers that genuinely have a single event (tests, a replay); the
/// live `/shell-event` route always delivers whole batches.
@MainActor public func observeShellEvent(
sessionID: SessionID, call: MCPApprovalServer.ShellReportCall
) async {
await observeShellEvents(sessionID: sessionID, calls: [call])
}
}
/// Pure, deterministic conflict detection the model-free core (mirrors /// Pure, deterministic conflict detection the model-free core (mirrors
/// `HeuristicTriage`). File-path overlap is the strong signal; a task-intent token /// `HeuristicTriage`). File-path overlap is the strong signal; a task-intent token
/// overlap is a secondary net for when the agent can't predict the files up front. /// overlap is a secondary net for when the agent can't predict the files up front.
+11 -2
View File
@@ -91,9 +91,18 @@ public actor ConflictCoordinator {
await arbiter?.observeCommand(sessionID: sessionID, call: call) await arbiter?.observeCommand(sessionID: sessionID, call: call)
} }
/// Forward a nash agent-shell event to the arbiter. Best-effort, as above. /// Forward one nash batch every event of a `/shell-event` post, in order to the arbiter.
/// Best-effort, as above.
public func observeShellEvents(
sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]
) async {
guard !calls.isEmpty else { return }
await arbiter?.observeShellEvents(sessionID: sessionID, calls: calls)
}
/// Forward a single nash agent-shell event to the arbiter. Best-effort, as above.
public func observeShellEvent(sessionID: SessionID, call: MCPApprovalServer.ShellReportCall) async { public func observeShellEvent(sessionID: SessionID, call: MCPApprovalServer.ShellReportCall) async {
await arbiter?.observeShellEvent(sessionID: sessionID, call: call) await arbiter?.observeShellEvents(sessionID: sessionID, calls: [call])
} }
/// Try to claim a host-run slot before a `host_exec` command runs (HOST_EXEC §concurrency). /// Try to claim a host-run slot before a `host_exec` command runs (HOST_EXEC §concurrency).
@@ -294,8 +294,8 @@ public actor ACPBackend: AgentBackend {
await server.registerCommandReport(token: token) { [weak self] call in await server.registerCommandReport(token: token) { [weak self] call in
Task { await self?.handleCommandReport(call) } Task { await self?.handleCommandReport(call) }
} }
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.handleShellReport(call) } Task { await self?.handleShellReport(calls) }
} }
await nucleicToolRuntime.configurePlatformToolRuntime(for: run) { await nucleicToolRuntime.configurePlatformToolRuntime(for: run) {
[weak self] kind, nativeType in [weak self] kind, nativeType in
@@ -571,9 +571,9 @@ public actor ACPBackend: AgentBackend {
} }
/// Forward a nash agent-shell event (`/shell-event`) for the feeds (docs/NASH.md §9). /// Forward a nash agent-shell event (`/shell-event`) for the feeds (docs/NASH.md §9).
private func handleShellReport(_ call: MCPApprovalServer.ShellReportCall) async { private func handleShellReport(_ calls: [MCPApprovalServer.ShellReportCall]) async {
guard let sessionID, let conflictCoordinator else { return } guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeShellEvent(sessionID: sessionID, call: call) await conflictCoordinator.observeShellEvents(sessionID: sessionID, calls: calls)
} }
/// Cooperatively cancel the in-flight turn: ACP `session/cancel` (a notification); the agent /// Cooperatively cancel the in-flight turn: ACP `session/cancel` (a notification); the agent
@@ -22,8 +22,10 @@ import Foundation
public actor HostShellEventListener { public actor HostShellEventListener {
public static let shared = HostShellEventListener() public static let shared = HostShellEventListener()
/// Delivery of one decoded shell event, tagged with the registering spawn's origin id. /// Delivery of one decoded shell **batch**, tagged with the registering spawn's origin id.
public typealias Sink = @Sendable (String, MCPApprovalServer.ShellReportCall) async -> Void /// Batch-shaped like the session route (docs/NASH_STREAM_PERF_PLAN.md §P1) the per-delivery
/// cost is the actor hop and the viewer's ring append, so a 200-event post pays it once.
public typealias Sink = @Sendable (String, [MCPApprovalServer.ShellReportCall]) async -> Void
private let server: MCPApprovalServer private let server: MCPApprovalServer
private var boundPort: UInt16? private var boundPort: UInt16?
@@ -54,8 +56,8 @@ public actor HostShellEventListener {
guard let port = boundPort, port != 0 else { return nil } guard let port = boundPort, port != 0 else { return nil }
let token = UUID().uuidString let token = UUID().uuidString
origins[token] = origin origins[token] = origin
await server.registerShellReport(token: token) { [weak self] call in await server.registerShellReport(token: token) { [weak self] calls in
Task { await self?.deliver(token: token, call: call) } Task { await self?.deliver(token: token, calls: calls) }
} }
return ("http://127.0.0.1:\(port)\(MCPApprovalServer.shellEventPath)", token) return ("http://127.0.0.1:\(port)\(MCPApprovalServer.shellEventPath)", token)
} }
@@ -66,8 +68,8 @@ public actor HostShellEventListener {
await server.unregister(token: token) await server.unregister(token: token)
} }
private func deliver(token: String, call: MCPApprovalServer.ShellReportCall) async { private func deliver(token: String, calls: [MCPApprovalServer.ShellReportCall]) async {
guard let origin = origins[token], let sink else { return } guard let origin = origins[token], let sink else { return }
await sink(origin, call) await sink(origin, calls)
} }
} }
+72 -20
View File
@@ -480,38 +480,76 @@ public final class NashEventLog {
/// The one emission that does not simply append is a command's completion: when nash already /// The one emission that does not simply append is a command's completion: when nash already
/// announced that command as running (docs/NASH.md §5.1), the finished row **replaces** the /// announced that command as running (docs/NASH.md §5.1), the finished row **replaces** the
/// in-flight one where it sits rather than landing as a second row for the same command see /// in-flight one where it sits rather than landing as a second row for the same command see
/// ``retireRunningRow(sessionID:call:event:)``. /// ``append(_:sessionID:call:to:)``.
@discardableResult @discardableResult
public func record( public func record(
sessionID: SessionID, call: MCPApprovalServer.ShellReportCall, sessionID: SessionID, call: MCPApprovalServer.ShellReportCall,
environment: NashEnvironment? = nil, at: Date = Date() environment: NashEnvironment? = nil, at: Date = Date()
) -> NashEvent { ) -> NashEvent {
// A running row is stamped with when its command *started*, not when we heard about it: record(sessionID: sessionID, calls: [call], environment: environment, at: at)[0]
// nash waits out its announce threshold and then the batch waits out a flush, so a row }
// clocked from arrival would show a build as a second younger than it is and would keep
// that error for as long as the row counts up. The completion inherits this stamp, so the /// Record one nash **batch** every event of a `/shell-event` post, in the order nash emitted
// finished row also sits at the time the command began. /// them. Returns the rows as they landed, one per call.
///
/// This is the shape the live route delivers (docs/NASH_STREAM_PERF_PLAN.md §P1): a batch of up
/// to 200 events costs *one* in-place mutation of the session's ring, one trim, and therefore
/// one `@Observable` invalidation, rather than paying all three per event. The ring is mutated
/// through ``withRing(_:_:)`` so appending never copies the 1000-row buffer.
@discardableResult
public func record(
sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall],
environment: NashEnvironment? = nil, at: Date = Date()
) -> [NashEvent] {
guard !calls.isEmpty else { return [] }
var landed: [NashEvent] = []
landed.reserveCapacity(calls.count)
// Trimming is deferred to the end of the batch so the ring is walked once; the in-flight
// rows the trim invalidates are pruned after, outside the ring's exclusive access.
var trimmedFrom: UInt64?
withRing(sessionID) { bucket in
for call in calls {
// A running row is stamped with when its command *started*, not when we heard about
// it: nash waits out its announce threshold and then the batch waits out a flush, so
// a row clocked from arrival would show a build as a second younger than it is and
// would keep that error for as long as the row counts up. The completion inherits
// this stamp, so the finished row also sits at the time the command began.
var at = at var at = at
if case .execStart(_, _, let elapsedMs, _) = call.kind { if case .execStart(_, _, let elapsedMs, _) = call.kind {
at -= Double(elapsedMs) / 1000 at -= Double(elapsedMs) / 1000
} }
let event = Self.project( let event = Self.project(
seq: nextSeq, at: at, sessionID: sessionID, seq: nextSeq, at: at, sessionID: sessionID,
environment: environment ?? call.environment ?? .agent, shellID: call.shellID ?? "", environment: environment ?? call.environment ?? .agent,
kind: call.kind) shellID: call.shellID ?? "", kind: call.kind)
landed.append(append(event, sessionID: sessionID, call: call, to: &bucket))
}
if bucket.count > capacity {
bucket.removeFirst(bucket.count - capacity)
trimmedFrom = bucket.first?.seq ?? 0
}
}
if let trimmedFrom { pruneRunningRows(sessionID: sessionID, below: trimmedFrom) }
return landed
}
/// Land one projected row in `bucket` appending it, or folding it into the in-flight row it
/// completes and do the bookkeeping that follows. Returns the row as it landed.
///
/// Takes the ring `inout` rather than reaching for `eventsBySession`, because its caller is
/// already holding that property under exclusive access (see ``withRing(_:_:)``); everything
/// else it touches is a different property.
private func append(
_ event: NashEvent, sessionID: SessionID, call: MCPApprovalServer.ShellReportCall,
to bucket: inout [NashEvent]
) -> NashEvent {
if case .exec = call.kind, if case .exec = call.kind,
let landed = retireRunningRow(sessionID: sessionID, call: call, event: event) let landed = retireRunningRow(sessionID: sessionID, call: call, event: event, in: &bucket)
{ {
return landed return landed
} }
nextSeq += 1 nextSeq += 1
var bucket = eventsBySession[sessionID] ?? []
bucket.append(event) bucket.append(event)
if bucket.count > capacity {
bucket.removeFirst(bucket.count - capacity)
pruneRunningRows(sessionID: sessionID, below: bucket.first?.seq ?? 0)
}
eventsBySession[sessionID] = bucket
environmentsBySession[sessionID, default: []].insert(event.environment) environmentsBySession[sessionID, default: []].insert(event.environment)
// An in-flight row is a *promise* of a row, not one of its own: it is not counted (its // An in-flight row is a *promise* of a row, not one of its own: it is not counted (its
// completion is the emission nash made), not written to the durable feed (a running row // completion is the emission nash made), not written to the durable feed (a running row
@@ -530,6 +568,18 @@ public final class NashEventLog {
return event return event
} }
/// Mutate one session's ring **in place**.
///
/// `&eventsBySession[sessionID, default: []]` threads `_modify` accessors all the way down the
/// `@Observable` property's and the dictionary subscript's so the body writes straight into
/// the stored buffer: no copy-on-write of the 1000-row array, and exactly one
/// willSet/didSet pair for the whole body however many rows it appends. Reading or writing
/// `eventsBySession` from inside `body` would overlap that exclusive access, so the body is
/// handed the ring and must use it.
private func withRing<R>(_ sessionID: SessionID, _ body: (inout [NashEvent]) -> R) -> R {
body(&eventsBySession[sessionID, default: []])
}
/// Identity of one command run in flight: nash's per-shell gate token, scoped by the shell that /// Identity of one command run in flight: nash's per-shell gate token, scoped by the shell that
/// issued it (the token counter restarts in every nash process, exactly like the pipeline one). /// issued it (the token counter restarts in every nash process, exactly like the pipeline one).
private struct RunningKey: Hashable { private struct RunningKey: Hashable {
@@ -559,20 +609,22 @@ public final class NashEventLog {
/// ///
/// A row the ring has already trimmed away is *not* resurrected: the key is dropped and the /// A row the ring has already trimmed away is *not* resurrected: the key is dropped and the
/// completion appends like any other, because a row nobody can see is not one to update. /// completion appends like any other, because a row nobody can see is not one to update.
///
/// `bucket` is the session's ring, held `inout` by the batch that is recording into it the
/// row this folds into may well have been appended by an earlier call of the same batch.
private func retireRunningRow( private func retireRunningRow(
sessionID: SessionID, call: MCPApprovalServer.ShellReportCall, event: NashEvent sessionID: SessionID, call: MCPApprovalServer.ShellReportCall, event: NashEvent,
in bucket: inout [NashEvent]
) -> NashEvent? { ) -> NashEvent? {
guard let key = runningKey(sessionID: sessionID, call: call), guard let key = runningKey(sessionID: sessionID, call: call),
let seq = runningRows.removeValue(forKey: key), let seq = runningRows.removeValue(forKey: key),
var bucket = eventsBySession[key.sessionID],
// Newest-last, and a running row is by definition recent, so this finds it immediately. // Newest-last, and a running row is by definition recent, so this finds it immediately.
let index = bucket.lastIndex(where: { $0.seq == seq }) let index = bucket.lastIndex(where: { $0.seq == seq })
else { return nil } else { return nil }
let landed = bucket[index].completed(by: event) let landed = bucket[index].completed(by: event)
bucket[index] = landed bucket[index] = landed
eventsBySession[key.sessionID] = bucket indexPipelineStage(landed, sessionID: sessionID)
indexPipelineStage(landed, sessionID: key.sessionID) totalsBySession[sessionID, default: [:]][landed.kind, default: 0] += 1
totalsBySession[key.sessionID, default: [:]][landed.kind, default: 0] += 1
enqueue(landed) enqueue(landed)
return landed return landed
} }
@@ -271,7 +271,7 @@ private final class StubArbiter: ConflictArbiter {
func observeGitOp(sessionID: SessionID, argv: [String], exitCode: Int) async {} func observeGitOp(sessionID: SessionID, argv: [String], exitCode: Int) async {}
func observeGhOp(sessionID: SessionID, argv: [String], exitCode: Int) async {} func observeGhOp(sessionID: SessionID, argv: [String], exitCode: Int) async {}
func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async {} func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async {}
func observeShellEvent(sessionID: SessionID, call: MCPApprovalServer.ShellReportCall) async {} func observeShellEvents(sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]) async {}
func beginHostCommand(sessionID: SessionID, command: String, override: Bool) async -> HostCommandClearance { func beginHostCommand(sessionID: SessionID, command: String, override: Bool) async -> HostCommandClearance {
.clear .clear
} }
@@ -39,9 +39,9 @@ import Testing
#expect(await listener.register(origin: "buildrun:p1") == nil) #expect(await listener.register(origin: "buildrun:p1") == nil)
// A tiny async inbox so the test can await delivery without polling shared state. // A tiny async inbox so the test can await delivery without polling shared state.
let inbox = AsyncStream.makeStream(of: (String, MCPApprovalServer.ShellReportCall).self) let inbox = AsyncStream.makeStream(of: (String, [MCPApprovalServer.ShellReportCall]).self)
await listener.install { origin, call in await listener.install { origin, calls in
inbox.continuation.yield((origin, call)) inbox.continuation.yield((origin, calls))
} }
let registration = try #require(await listener.register(origin: "buildrun:p1")) let registration = try #require(await listener.register(origin: "buildrun:p1"))
@@ -57,8 +57,9 @@ import Testing
var iterator = inbox.stream.makeAsyncIterator() var iterator = inbox.stream.makeAsyncIterator()
let delivered = try #require(await iterator.next()) let delivered = try #require(await iterator.next())
#expect(delivered.0 == "buildrun:p1") #expect(delivered.0 == "buildrun:p1")
#expect(delivered.1.count == 1)
#expect( #expect(
delivered.1.kind delivered.1.first?.kind
== .exec( == .exec(
argv: ["swift", "build"], cwd: "/proj", exitCode: 0, durationMs: 5, argv: ["swift", "build"], cwd: "/proj", exitCode: 0, durationMs: 5,
pipeline: nil)) pipeline: nil))
@@ -71,9 +72,9 @@ import Testing
@Test func concurrentSpawnsKeepDistinctOrigins() async throws { @Test func concurrentSpawnsKeepDistinctOrigins() async throws {
let listener = HostShellEventListener() let listener = HostShellEventListener()
let inbox = AsyncStream.makeStream(of: (String, MCPApprovalServer.ShellReportCall).self) let inbox = AsyncStream.makeStream(of: (String, [MCPApprovalServer.ShellReportCall]).self)
await listener.install { origin, call in await listener.install { origin, calls in
inbox.continuation.yield((origin, call)) inbox.continuation.yield((origin, calls))
} }
let a = try #require(await listener.register(origin: "buildrun:alpha")) let a = try #require(await listener.register(origin: "buildrun:alpha"))
let b = try #require(await listener.register(origin: "buildrun:beta")) let b = try #require(await listener.register(origin: "buildrun:beta"))
@@ -304,6 +304,66 @@ import Testing
#expect(log.stages(for: session, pipeline: .init(shellID: "sh-1", pipelineID: 1)).isEmpty) #expect(log.stages(for: session, pipeline: .init(shellID: "sh-1", pipelineID: 1)).isEmpty)
#expect(log.stages(for: session, pipeline: .init(shellID: "sh-1", pipelineID: 3))[0] != nil) #expect(log.stages(for: session, pipeline: .init(shellID: "sh-1", pipelineID: 3))[0] != nil)
} }
/// A whole nash batch lands in one pass (docs/NASH_STREAM_PERF_PLAN.md §P1) and has to be
/// indistinguishable from the same events recorded one at a time: order preserved, the trim
/// applied once at the end, and a completion still folding into the announcement it retires
/// including when both arrive in the *same* batch, which is what a 500 ms flush makes common.
@MainActor
@Test func recordsAWholeBatchInOnePass() {
let log = NashEventLog(capacity: 3)
func call(_ kind: MCPApprovalServer.ShellReportCall.Kind, token: Int? = nil)
-> MCPApprovalServer.ShellReportCall
{
.init(kind: kind, sessionID: session.rawValue, shellID: "sh-1", execToken: token)
}
let landed = log.record(
sessionID: session,
calls: [
call(.cd(from: "/", to: "/a")),
call(.execStart(argv: ["make"], cwd: "/a", elapsedMs: 450, pipeline: nil), token: 7),
call(.cd(from: "/a", to: "/b")),
call(
.exec(argv: ["make"], cwd: "/a", exitCode: 0, durationMs: 900, pipeline: nil),
token: 7),
call(.dropped(count: 2)),
], at: at)
// Five calls in, five rows back but the completion folded into the announcement, so the
// ring holds four, and the trim to `capacity` ran once over the finished batch.
#expect(landed.count == 5)
#expect(landed[1].id == landed[3].id)
#expect(log.events(for: session).map(\.summary) == ["make", "cd /b", "dropped 2 events under back-pressure"])
#expect(log.events(for: session).map(\.isRunning) == [false, false, false])
#expect(log.events(for: session)[0].detail == "in /a · 900ms")
// The trimmed `cd /a` still counts, and the announcement still counts only once.
#expect(log.total(for: session, kind: .cd) == 2)
#expect(log.total(for: session, kind: .exec) == 1)
#expect(log.total(for: session, kind: .dropped) == 1)
}
/// An announcement whose completion arrives in a *later* batch still finds its row the ring
/// the batch mutates is the same one the previous batch appended to.
@MainActor
@Test func batchesJoinAcrossDeliveries() {
let log = NashEventLog()
log.record(
sessionID: session,
calls: [
.init(
kind: .execStart(argv: ["make"], cwd: "/w", elapsedMs: 450, pipeline: nil),
sessionID: session.rawValue, shellID: "sh-1", execToken: 7)
], at: at)
log.record(
sessionID: session,
calls: [
.init(
kind: .exec(argv: ["make"], cwd: "/w", exitCode: 2, durationMs: 9000, pipeline: nil),
sessionID: session.rawValue, shellID: "sh-1", execToken: 7)
], at: at + 9)
#expect(log.events(for: session).count == 1)
#expect(log.events(for: session)[0].detail == "in /w · exit 2 · 9.0s")
}
} }
/// The durable half of the feed (docs/NASH.md §9.4): rows written through `NashEventPersisting` /// The durable half of the feed (docs/NASH.md §9.4): rows written through `NashEventPersisting`
+37 -4
View File
@@ -545,6 +545,14 @@ toText, bytes, truncated, preview, hash}` via mechanism (b). This surfaces what
transcript never shows today — the intermediate data between stages (`curl … | sh` transcript never shows today — the intermediate data between stages (`curl … | sh`
becomes *visible*). becomes *visible*).
**How the copy runs** (docs/NASH_STREAM_PERF_PLAN.md §P2): only the captured prefix passes
through userspace. Once `CAP` bytes are mirrored the copier switches to `splice(2)` on Linux —
pipe-to-pipe inside the kernel, with the byte count coming back from the same call that moves
them — and falls back to a large-buffer read/write loop on macOS or wherever the kernel refuses
the splice. Both ends of a tapped link are grown with `F_SETPIPE_SZ` where that is allowed, and
the copier threads are pooled per process rather than spawned per link, since thread creation was
most of what a tapped pipeline cost to *set up*.
`fromText`/`toText` name the commands either side of the link, rendered from their AST `fromText`/`toText` name the commands either side of the link, rendered from their AST
when the tee is spawned. They are carried on the link rather than inferred from the when the tee is spawned. They are carried on the link rather than inferred from the
stages' `exec` events for two reasons: a stage that is a compound command stages' `exec` events for two reasons: a stage that is a compound command
@@ -568,11 +576,23 @@ double event volume for no new signal.
### 5.4 Caps, redaction, volume ### 5.4 Caps, redaction, volume
- Per-event preview cap (64 KiB), per-command total capture cap (256 KiB), per-batch cap - Per-event preview cap (64 KiB), per-command total capture cap (256 KiB), per-batch cap
(1 MiB); beyond caps → counts/hashes only, `truncated: true`. (1 MiB); beyond caps → counts/hashes only, `truncated: true`. The per-batch cap is spent in
arrival order, so a pipeline storm loses its *tail* of previews and never an event: every link
still reports its byte count and its hash.
- Hashes cover the **captured prefix**, uniformly across the three data-flow kinds. A pipe's
full stream is never buffered, so it never could cover more; making the file and cmdsub kinds
agree keeps the marker's meaning single ("these previews carried the same bytes") and keeps
the hash off the paths a command waits on.
- Redaction runs *inside nash before bytes leave the guest*: the same posture as - Redaction runs *inside nash before bytes leave the guest*: the same posture as
[OBSERVABILITY_AND_TESTING.md](OBSERVABILITY_AND_TESTING.md) — pattern-based masking of [OBSERVABILITY_AND_TESTING.md](OBSERVABILITY_AND_TESTING.md) — pattern-based masking of
obvious credential shapes (bearer/PAT/AWS-style tokens, `PRIVATE KEY` blocks) in previews; obvious credential shapes (bearer/PAT/AWS-style tokens, `PRIVATE KEY` blocks) in previews;
env vars are **never** captured wholesale (exec events carry argv, not environment). env vars are **never** captured wholesale (exec events carry argv, not environment).
- **Where that work runs** (docs/NASH_STREAM_PERF_PLAN.md §P3): a command's own path pays only
for *capture* — the bytes, plus one `stat` to fix which range of a redirected file this command
wrote. Reading that range back, redacting, base64-ing and hashing all happen on the flusher
thread as the batch is serialized. The range is measured at exit rather than at flush precisely
so that `echo a > f; echo b >> f` still reports one line each: the reading is deferred, the
measurement never is.
- Environment mutations are **names only** (locked decision): `export`/`declare`/`unset` - Environment mutations are **names only** (locked decision): `export`/`declare`/`unset`
produce events carrying the variable name (`export AWS_SECRET_ACCESS_KEY`) and never the produce events carrying the variable name (`export AWS_SECRET_ACCESS_KEY`) and never the
value — the name alone is the signal that a credential-shaped variable was set, with zero value — the name alone is the signal that a credential-shaped variable was set, with zero
@@ -705,6 +725,12 @@ is — the event model is the same, the liveness is a property of the transport.
Auth is the existing per-session bearer token (`NUCLEIC_HOOK_TOKEN`); unknown tokens get Auth is the existing per-session bearer token (`NUCLEIC_HOOK_TOKEN`); unknown tokens get
401 and nash stops posting (spools) for that process. 401 and nash stops posting (spools) for that process.
The connection is **kept open** across batches (docs/NASH_STREAM_PERF_PLAN.md §P4.1): a shell
posts every 500 ms for as long as it lives, and the host's route already answers `Connection:
keep-alive` with a `Content-Length`. nash reads each response to its end, so the socket stays
framed for the next batch; a connection the host has since closed costs one retry on a fresh one,
after which the ordinary spool fallback applies.
--- ---
## 7. Forcing nash as the default shell — per surface ## 7. Forcing nash as the default shell — per surface
@@ -1074,7 +1100,14 @@ not scheduled work in this plan.
## 9. Host-side integration (Swift) ## 9. Host-side integration (Swift)
Follows the existing report pipeline shape exactly (shim → route → struct → backend → Follows the existing report pipeline shape exactly (shim → route → struct → backend →
coordinator → AppStore → feed): coordinator → AppStore → feed).
**The unit of delivery is the batch, not the event** (docs/NASH_STREAM_PERF_PLAN.md §P1). One
POST carries up to 200 events, and every per-delivery cost the host pays — the unstructured
`Task`, the hop to the main actor, the viewer's ring append and its `@Observable` invalidation —
is paid once for the whole batch. Delivering per event also silently reordered a batch, because
unstructured `Task`s do not run in creation order: an `exec` could land ahead of the `exec-start`
it retires. Handlers therefore take `[ShellReportCall]`, in nash's own order.
1. **Route**: `shellEventPath = "/shell-event"` in `MCPApprovalServer.swift` (beside 1. **Route**: `shellEventPath = "/shell-event"` in `MCPApprovalServer.swift` (beside
`gitEventPath:649`); dispatch beside lines 1752-1760; `handleShellEvent` parses `gitEventPath:649`); dispatch beside lines 1752-1760; `handleShellEvent` parses
@@ -1084,9 +1117,9 @@ coordinator → AppStore → feed):
2. **Registration**: `registerShellReport(token:handler:)` beside 2. **Registration**: `registerShellReport(token:handler:)` beside
`registerCommandReport` (1410-1425); wired in all four backends `registerCommandReport` (1410-1425); wired in all four backends
(`ClaudeCodeBackend.swift:2721-2744` and Codex/Grok twins). (`ClaudeCodeBackend.swift:2721-2744` and Codex/Grok twins).
3. **Coordinator**: `ConflictCoordinator.observeShellEvent(sessionID:call:)` forwarding to 3. **Coordinator**: `ConflictCoordinator.observeShellEvents(sessionID:calls:)` forwarding to
`AppStore`. `AppStore`.
4. **AppStore**: `observeShellEvent` — 4. **AppStore**: `observeShellEvents` —
- `exec` events flow into the existing `observeCommand` path (`AppStore.swift:4666`) - `exec` events flow into the existing `observeCommand` path (`AppStore.swift:4666`)
via `CommandSummary.classify(argv:)`, tagged `source: .nash`, preserving the NVRSION via `CommandSummary.classify(argv:)`, tagged `source: .nash`, preserving the NVRSION
residue-capture backstop (4679-4689) and `CMDTRACE` logging; residue-capture backstop (4679-4689) and `CMDTRACE` logging;
+74 -2
View File
@@ -1,7 +1,11 @@
# Nash stream performance: findings & optimization plan # Nash stream performance: findings & optimization plan
*Investigated 2026-07-29. Companion to [NASH.md](NASH.md) (§5 data-flow taps, §6 transport, *Investigated 2026-07-29; P1P4 and the regression guard implemented 2026-07-29. Companion to
§9 host ingest) and [MAIN_THREAD_PERFORMANCE_PLAN.md](MAIN_THREAD_PERFORMANCE_PLAN.md).* [NASH.md](NASH.md) (§5 data-flow taps, §6 transport, §9 host ingest) and
[MAIN_THREAD_PERFORMANCE_PLAN.md](MAIN_THREAD_PERFORMANCE_PLAN.md).*
**Status: all four phases shipped.** What landed, and where it deviates from the plan below, is
recorded in [§4 Outcome](#4-outcome).
Nash's observation stream was suspected of significantly affecting performance. This doc Nash's observation stream was suspected of significantly affecting performance. This doc
records what was measured, where the cost actually is, and a prioritized plan to reduce it. records what was measured, where the cost actually is, and a prioritized plan to reduce it.
@@ -143,3 +147,71 @@ Extend `shell/corpus/overhead.py`:
- `on_cmdsub` coverage gap for substitutions in assignments (`x=$(…)`). - `on_cmdsub` coverage gap for substitutions in assignments (`x=$(…)`).
- Brush's ~+35% bare-exec baseline vs bash (spawn-path work, independent of observation). - Brush's ~+35% bare-exec baseline vs bash (spawn-path work, independent of observation).
---
## 4. Outcome
### What shipped
**P1 — batch-preserving host ingest.** `ShellReportHandler` takes `[ShellReportCall]`;
`handleShellEvent` delivers one parsed batch per POST, through one `Task` and one MainActor hop,
to `ConflictArbiter.observeShellEvents(sessionID:calls:)`
`AppStore.observeShellEvents`. `NashEventLog.record(sessionID:calls:…)` lands the whole batch in
a single in-place mutation of the session ring (`withRing`, which threads `_modify` through the
`@Observable` property and the dictionary subscript), with one trim and one invalidation for the
batch instead of per event — and no copy-on-write of the 1000-row buffer, which the old
read-modify-write did on *every* event. `HostShellEventListener`'s app-scoped sink and the VM
spool drain are batch-shaped too. Per-event `CMDTRACE` lines dropped to `.debug`; `fallback`,
`dropped` and `policy` — the rare ones — kept their levels.
**P2 — cheaper tee.** After the 64 KiB prefix, a tapped link is moved with `splice(2)` on Linux
(pipe-to-pipe in the kernel, byte count from the same call), falling back to a 128 KiB
read/write loop on macOS or on `EINVAL`/`ENOSYS`/`EPERM`. Both pipes are grown with
`F_SETPIPE_SZ` where permitted, and copier threads are pooled per process (≤4 idle, 512 KiB
stacks) instead of spawned per link — with a pid check so a `fork` can never hand a job to a
worker that only exists in the parent.
**P3 — encoding off the hot paths.** Data-flow events carry a `Capture` — raw bytes, or a
measured-but-unread file range — and `Serialize` does the redaction, base64 and FNV hash, which
means all of it runs on the flusher thread. Binary hex previews are table-driven.
**P4 — transport.** One connection, kept open across every batch of a shell's life
(`Connection: keep-alive`, responses drained to their `Content-Length` so the socket stays
framed, one retry on a connection the host has since closed). Plus NASH.md §5.4's per-batch
preview budget, which was specified but never implemented: 1 MiB of payload per batch, spent in
arrival order, beyond which events keep their counts and hashes and give up their previews.
**Regression guard.** `overhead.py` reports CPU (rusage) beside wall for every case, adds
`--stream-mb` throughput cases (one and two links) gated on CPU, defaults `--bash` to
`/usr/bin/bash.real`, and refuses a baseline that reports `NUCLEIC_NASH=1` (probed with a
scrubbed environment, since the harness itself usually runs *under* nash).
### Measured (aarch64 Linux container, release build, observed → spool)
| Case | before | after |
|---|---|---|
| 256 MB through `a\|b` | +0.2% wall / **+11.9% CPU** | 22.4% wall / **11.3% CPU** |
| 256 MB through `a\|b\|c` | +29.8% wall / **+100.9% CPU** | 35.8% wall / **+11.4% CPU** |
| 300 tiny pipelines | 0.220 s wall / 0.328 s CPU | 0.209 s wall / 0.309 s CPU |
The two-link stream case — the one that doubled CPU — now costs about a tenth of that, and both
stream cases run *faster than bash* in wall time, because the tee's extra pipe adds pipelining
the kernel now moves for free. The pipeline-storm case improves ~5% from the thread pool; most of
what remains there is brush's spawn baseline, which is the separate workstream noted above.
### Deviations from the plan
- **P3.2 measures at exit, reads at flush.** Moving the redirect read-back wholesale to the
flusher is wrong, not merely stale: `echo a > f; echo b >> f` flushes as one batch, and by then
the truncating write's read-back would report both lines. The command path keeps one `stat`
(which also answers "is this a regular file at all"), fixing the range; everything
else — open, read, hash, redact, encode — moved. Guarded by
`deferred_readback_reads_the_measured_range`.
- **Hashes are uniformly over the captured prefix.** `cmdsub` used to hash its *entire* output,
which meant `x=$(cat 32MB)` hashed 32 MB on the interpreter thread. Carrying those bytes to the
flusher to preserve that would have cost more than the hash. Pipes already hashed the prefix;
files never exceed it. NASH.md §5.4 now says so.
- **P1.4 (UI coalescing) not done, and not needed yet.** Batching already reduced the viewer's
invalidations to one per POST — at most two per second per shell, which SwiftUI absorbs
without a rate limiter. Worth revisiting only if a profile says otherwise.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "nucleic",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+200 -25
View File
@@ -1,22 +1,44 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Corpus wall-clock overhead harness for nash (docs/NASH.md §11, M2/M3 gate: <3%). """Corpus overhead harness for nash (docs/NASH.md §11, M2/M3 gate: <3%).
Replays the corpus under bash and nash in alternating rounds and compares total Replays the corpus under bash and nash in alternating rounds and compares total
wall-clock per shell. Only the shell subprocess is timed (fixture seeding and wall-clock **and CPU time** per shell. Only the shell subprocess is measured
filesystem snapshots are outside the clock). Rounds alternate shell order so (fixture seeding and filesystem snapshots are outside both clocks). Rounds
cache/thermal drift cancels; the reported figure uses the median round total. alternate shell order so cache/thermal drift cancels; the reported figures use
the median round total.
Two measurements, because they answer different questions
(docs/NASH_STREAM_PERF_PLAN.md §2):
* **wall** is what a single command waits for — the M3 gate.
* **cpu** (user+sys of the shell and everything it spawned, via `rusage`) is
what nash actually spends. The pipe tee runs on its own thread, so on idle
hardware it can add CPU while *costing no wall time at all* — and a
wall-only harness would report that as free. It is not: the deployed box
runs many agents at once, where that CPU comes out of everyone's clock.
Beyond the corpus, `--stream-mb` runs a **throughput** case — hundreds of MB
across one and two pipe links — which is the shape the tee is optimized for and
the one the corpus (a few KB per command) cannot see.
Usage: overhead.py [--nash PATH] [--bash PATH] [--corpus PATH] [--rounds N] Usage: overhead.py [--nash PATH] [--bash PATH] [--corpus PATH] [--rounds N]
[--observe-spool DIR] [--gate PCT] [--json PATH] [--observe-spool DIR] [--gate PCT] [--stream-mb MB]
[--stream-gate PCT] [--json PATH] [--allow-nash-baseline]
--observe-spool enables nash observation (spool transport) so the measured --observe-spool enables nash observation (spool transport) so the measured
configuration is the deployed one; the env is set identically for bash, where configuration is the deployed one; the env is set identically for bash, where
it is inert. it is inert.
The baseline shell must be a *real* bash: on a Nucleic-managed box `/bin/bash`
IS nash (docs/NASH.md §7), so the default baseline is `/usr/bin/bash.real` and
a baseline that reports `NUCLEIC_NASH=1` is refused outright — benchmarking
nash against itself reports ~0% overhead and means nothing.
""" """
import argparse import argparse
import json import json
import os import os
import resource
import shutil import shutil
import statistics import statistics
import subprocess import subprocess
@@ -26,8 +48,26 @@ import time
from replay import FIXTURES, TIMEOUT_S, seed from replay import FIXTURES, TIMEOUT_S, seed
#: Where a Nucleic-managed image keeps the real bash after the nash divert.
DEFAULT_BASH = "/usr/bin/bash.real"
def run_timed(shell, cmd, extra_env):
def child_cpu():
"""User+sys seconds of every child this process has reaped."""
usage = resource.getrusage(resource.RUSAGE_CHILDREN)
return usage.ru_utime + usage.ru_stime
def run_timed(shell, cmd, extra_env, workdir=None):
"""Run one command, returning (wall_seconds, cpu_seconds).
CPU comes from the delta of `RUSAGE_CHILDREN`, which only counts children
already reaped — `subprocess.run` waits, and the harness runs one child at a
time, so the delta is exactly this command's shell and its descendants
(including nash's tee and flusher threads).
"""
parent = None
if workdir is None:
parent = tempfile.mkdtemp(prefix="nash-overhead-") parent = tempfile.mkdtemp(prefix="nash-overhead-")
workdir = os.path.join(parent, "workspace") workdir = os.path.join(parent, "workspace")
os.makedirs(workdir) os.makedirs(workdir)
@@ -42,6 +82,7 @@ def run_timed(shell, cmd, extra_env):
} }
if extra_env: if extra_env:
env.update(extra_env) env.update(extra_env)
cpu_before = child_cpu()
start = time.monotonic() start = time.monotonic()
try: try:
subprocess.run( subprocess.run(
@@ -54,24 +95,136 @@ def run_timed(shell, cmd, extra_env):
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
cpu = child_cpu() - cpu_before
if parent:
shutil.rmtree(parent, ignore_errors=True) shutil.rmtree(parent, ignore_errors=True)
return elapsed return elapsed, cpu
def is_nash(shell):
"""Whether `shell` is nash wearing another name (docs/NASH.md §2).
Probed with a scrubbed environment on purpose: the harness itself is very
likely running *under* nash, which exports `NUCLEIC_NASH=1` to everything it
spawns — inheriting that would make every shell look like nash. Only a shell
that sets the variable for itself answers 1 here.
"""
try:
out = subprocess.run(
[shell, "-c", 'printf %s "${NUCLEIC_NASH-}"'],
capture_output=True,
timeout=30,
text=True,
env={"PATH": "/usr/bin:/bin"},
)
except (OSError, subprocess.SubprocessError):
return False
return out.stdout.strip() == "1"
def stream_cases(megabytes):
"""Throughput scripts: the same payload across one link and across two.
`/dev/zero → /dev/null` deliberately: the point is the cost of *carrying*
bytes across a tapped link, so neither end should be doing work of its own.
"""
count = megabytes * 1024 * 1024
return [
(f"1 link ({megabytes} MB)", f"head -c {count} /dev/zero | cat > /dev/null"),
(
f"2 links ({megabytes} MB)",
f"head -c {count} /dev/zero | cat | cat > /dev/null",
),
]
def measure(shell, cmds, extra_env):
"""Total (wall, cpu) for one pass over `cmds`."""
wall = cpu = 0.0
for cmd in cmds:
w, c = run_timed(shell, cmd, extra_env)
wall += w
cpu += c
return wall, cpu
def percent(nash, bash):
"""nash's cost over bash's, in percent. Infinite-safe for a zero baseline."""
return 100.0 * (nash / bash - 1.0) if bash > 0 else float("nan")
def report(label, bash_vals, nash_vals, gate=None):
"""Print (and return) one comparison's medians and percentages."""
bash_wall = statistics.median(w for w, _ in bash_vals)
bash_cpu = statistics.median(c for _, c in bash_vals)
nash_wall = statistics.median(w for w, _ in nash_vals)
nash_cpu = statistics.median(c for _, c in nash_vals)
result = {
"bash_wall_s": bash_wall,
"bash_cpu_s": bash_cpu,
"nash_wall_s": nash_wall,
"nash_cpu_s": nash_cpu,
"wall_percent": percent(nash_wall, bash_wall),
"cpu_percent": percent(nash_cpu, bash_cpu),
}
print(f"\n{label}")
print(f" bash: {bash_wall:.3f}s wall / {bash_cpu:.3f}s cpu")
print(f" nash: {nash_wall:.3f}s wall / {nash_cpu:.3f}s cpu")
suffix = f" (gate: <{gate:.1f}%)" if gate is not None else ""
print(
f" overhead: {result['wall_percent']:+.2f}% wall / "
f"{result['cpu_percent']:+.2f}% cpu{suffix}"
)
return result
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--nash", default=os.environ.get("NASH_BIN", "nash")) ap.add_argument("--nash", default=os.environ.get("NASH_BIN", "nash"))
ap.add_argument("--bash", default="/bin/bash") ap.add_argument(
"--bash",
default=DEFAULT_BASH,
help=f"baseline shell — must not be nash (default {DEFAULT_BASH})",
)
ap.add_argument( ap.add_argument(
"--corpus", "--corpus",
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "corpus.jsonl"), default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "corpus.jsonl"),
) )
ap.add_argument("--rounds", type=int, default=5) ap.add_argument("--rounds", type=int, default=5)
ap.add_argument("--observe-spool", help="enable nash observation, spooling to this dir") ap.add_argument("--observe-spool", help="enable nash observation, spooling to this dir")
ap.add_argument("--gate", type=float, default=3.0, help="max overhead percent") ap.add_argument("--gate", type=float, default=3.0, help="max corpus wall overhead percent")
ap.add_argument(
"--stream-mb",
type=int,
default=256,
help="payload per throughput case (0 disables the stream cases)",
)
ap.add_argument(
"--stream-gate",
type=float,
default=50.0,
help="max stream CPU overhead percent (the tee's own cost)",
)
ap.add_argument(
"--allow-nash-baseline",
action="store_true",
help="benchmark against a nash baseline anyway (produces meaningless numbers)",
)
ap.add_argument("--json", help="also write results to this path") ap.add_argument("--json", help="also write results to this path")
args = ap.parse_args() args = ap.parse_args()
# A baseline that is itself nash makes every number here ~0% and hides whatever
# regressed. On a Nucleic box that is the *default* state of /bin/bash, so this is
# a refusal rather than a warning (docs/NASH_STREAM_PERF_PLAN.md §regression guard).
if is_nash(args.bash) and not args.allow_nash_baseline:
print(
f"error: baseline shell {args.bash} reports NUCLEIC_NASH=1 — it IS nash.\n"
f" Point --bash at the real bash ({DEFAULT_BASH} on a Nucleic image),\n"
" or pass --allow-nash-baseline if you really mean to compare nash to nash.",
file=sys.stderr,
)
return 2
observe_env = None observe_env = None
if args.observe_spool: if args.observe_spool:
os.makedirs(args.observe_spool, exist_ok=True) os.makedirs(args.observe_spool, exist_ok=True)
@@ -82,6 +235,7 @@ def main():
with open(args.corpus) as f: with open(args.corpus) as f:
cmds = [json.loads(line)["cmd"] for line in f if line.strip()] cmds = [json.loads(line)["cmd"] for line in f if line.strip()]
streams = stream_cases(args.stream_mb) if args.stream_mb > 0 else []
# Warm-up: one untimed pass per shell (page cache, binary load). # Warm-up: one untimed pass per shell (page cache, binary load).
for shell in (args.bash, args.nash): for shell in (args.bash, args.nash):
@@ -89,22 +243,33 @@ def main():
run_timed(shell, cmd, observe_env) run_timed(shell, cmd, observe_env)
totals = {"bash": [], "nash": []} totals = {"bash": [], "nash": []}
stream_totals = {name: {"bash": [], "nash": []} for name, _ in streams}
for round_no in range(args.rounds): for round_no in range(args.rounds):
order = [("bash", args.bash), ("nash", args.nash)] order = [("bash", args.bash), ("nash", args.nash)]
if round_no % 2: if round_no % 2:
order.reverse() order.reverse()
for name, shell in order: for name, shell in order:
total = sum(run_timed(shell, cmd, observe_env) for cmd in cmds) wall, cpu = measure(shell, cmds, observe_env)
totals[name].append(total) totals[name].append((wall, cpu))
print(f"round {round_no + 1} {name}: {total:.3f}s", flush=True) print(f"round {round_no + 1} {name}: {wall:.3f}s wall / {cpu:.3f}s cpu", flush=True)
for case, script in streams:
stream_totals[case][name].append(run_timed(shell, script, observe_env))
bash_med = statistics.median(totals["bash"]) corpus = report(
nash_med = statistics.median(totals["nash"]) f"corpus ({len(cmds)} cmds x {args.rounds} rounds)",
overhead = 100.0 * (nash_med / bash_med - 1.0) totals["bash"],
totals["nash"],
gate=args.gate,
)
stream_results = {
case: report(f"stream {case}", vals["bash"], vals["nash"], gate=args.stream_gate)
for case, vals in stream_totals.items()
}
print(f"\ncorpus: {len(cmds)} cmds x {args.rounds} rounds") corpus_ok = corpus["wall_percent"] < args.gate
print(f"bash median: {bash_med:.3f}s nash median: {nash_med:.3f}s") # The stream cases are gated on CPU: the tee's cost is a copier thread, which idle
print(f"overhead: {overhead:+.2f}% (gate: <{args.gate:.1f}%)") # hardware hides from wall-clock entirely.
stream_ok = all(r["cpu_percent"] < args.stream_gate for r in stream_results.values())
if args.json: if args.json:
with open(args.json, "w") as f: with open(args.json, "w") as f:
@@ -112,20 +277,30 @@ def main():
{ {
"cmds": len(cmds), "cmds": len(cmds),
"rounds": args.rounds, "rounds": args.rounds,
"totals": totals, "totals": {k: [{"wall_s": w, "cpu_s": c} for w, c in v] for k, v in totals.items()},
"bash_median_s": bash_med, "corpus": corpus,
"nash_median_s": nash_med, "streams": stream_results,
"overhead_percent": overhead, "stream_mb": args.stream_mb,
"gate_percent": args.gate, "gate_percent": args.gate,
"stream_gate_percent": args.stream_gate,
"observed": bool(args.observe_spool), "observed": bool(args.observe_spool),
"bash": args.bash,
"nash": args.nash,
# Kept for readers of the pre-CPU schema.
"bash_median_s": corpus["bash_wall_s"],
"nash_median_s": corpus["nash_wall_s"],
"overhead_percent": corpus["wall_percent"],
}, },
f, f,
indent=1, indent=1,
) )
gate = overhead < args.gate print(f"\nM3 overhead gate (corpus wall <{args.gate:.1f}%): {'PASS' if corpus_ok else 'FAIL'}")
print(f"M3 overhead gate (<{args.gate:.1f}%): {'PASS' if gate else 'FAIL'}") if streams:
return 0 if gate else 1 print(
f"stream gate (cpu <{args.stream_gate:.1f}%): {'PASS' if stream_ok else 'FAIL'}"
)
return 0 if corpus_ok and stream_ok else 1
if __name__ == "__main__": if __name__ == "__main__":
+600 -117
View File
@@ -18,6 +18,7 @@ use std::sync::mpsc;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::ser::SerializeMap;
use serde::Serialize; use serde::Serialize;
const FLUSH_MAX_EVENTS: usize = 200; const FLUSH_MAX_EVENTS: usize = 200;
@@ -35,11 +36,144 @@ const IO_TIMEOUT: Duration = Duration::from_millis(1500);
const EXIT_FLUSH_TIMEOUT: Duration = Duration::from_millis(2000); const EXIT_FLUSH_TIMEOUT: Duration = Duration::from_millis(2000);
/// Per-redirection / per-cmdsub preview cap in bytes (docs/NASH.md §5.4). /// Per-redirection / per-cmdsub preview cap in bytes (docs/NASH.md §5.4).
const PREVIEW_CAP: usize = 64 * 1024; const PREVIEW_CAP: usize = 64 * 1024;
/// Captured payload one batch may carry, in raw bytes (docs/NASH.md §5.4's per-batch cap).
/// Beyond it the events keep their counts and hashes and lose their previews — a pipeline storm
/// can otherwise put 200 × 64 KiB of preview in one body, which the host then has to parse,
/// base64-decode and hold (docs/NASH_STREAM_PERF_PLAN.md §P4.2).
const BATCH_PREVIEW_BUDGET: usize = 1024 * 1024;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event model (docs/NASH.md §6.1) // Event model (docs/NASH.md §6.1)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// The captured half of a data-flow event (`redirect` / `cmdsub` / `pipe`) — everything the wire
/// calls `bytes`, `truncated`, `hash` and `previewB64`.
///
/// It exists so that **none of that encoding happens where the bytes were captured**
/// (docs/NASH_STREAM_PERF_PLAN.md §P3). The interpreter thread, the tee thread and the command's
/// own exit path hand over raw bytes; redaction, base64 and the FNV hash run inside
/// [`Serialize`] — which is only ever called from the flusher thread, off every path a command
/// waits on. The wire shape is unchanged.
enum Capture {
/// Nothing was captured: a special file (`/dev/null`, a tty, a socket), or a target that
/// could not be read. Counts as zero, and carries no hash — an empty hash is how the host
/// tells "no payload" from "a payload that happened to be empty".
None,
/// A file range that has not been read back yet; the flusher opens and reads it
/// (docs/NASH_STREAM_PERF_PLAN.md §P3.2). `offset`/`total` are fixed at the moment the
/// command exited, so a later command appending to the same file cannot widen this event's
/// range — only the *reading* is deferred, never the measurement.
Deferred {
path: PathBuf,
offset: u64,
total: u64,
},
/// Bytes in hand, raw and already capped to [`PREVIEW_CAP`].
Bytes {
bytes: u64,
truncated: bool,
data: Vec<u8>,
},
/// A preview dropped by the per-batch budget (docs/NASH.md §5.4): counts and hash survive,
/// and `truncated` is true because the preview no longer stands for the bytes.
Counted { bytes: u64, hash: String },
}
impl Capture {
/// Bytes in hand, capped to the preview cap.
fn from_bytes(total: u64, data: &[u8]) -> Self {
let capped = data.len().min(PREVIEW_CAP);
Self::Bytes {
bytes: total,
truncated: total > capped as u64,
data: data[..capped].to_vec(),
}
}
/// The payload this will preview, in raw bytes — what the per-batch budget is spent on.
const fn preview_len(&self) -> usize {
match self {
Self::Bytes { data, .. } => data.len(),
// The budget is spent after the deferred reads are resolved, so this arm is only
// reached for a range that could not be read; charge its bounded worst case anyway
// rather than letting an unresolved capture spend nothing.
Self::Deferred { total, .. } => {
if *total > PREVIEW_CAP as u64 {
PREVIEW_CAP
} else {
*total as usize
}
}
Self::None | Self::Counted { .. } => 0,
}
}
/// Read back a deferred range (flusher thread only). A range that has since become
/// unreadable degrades to [`Capture::None`] — the event still reports what it observed.
fn resolve(&mut self) {
let Self::Deferred {
path,
offset,
total,
} = self
else {
return;
};
*self = read_range(path, *offset, *total).map_or(Self::None, |(total, data)| Self::Bytes {
bytes: total,
truncated: total > data.len() as u64,
data,
});
}
/// Drop the preview, keeping the counts and the hash (the per-batch budget).
fn count_only(&mut self) {
if let Self::Bytes { bytes, data, .. } = self {
*self = Self::Counted {
bytes: *bytes,
hash: fnv1a_hex(data),
};
}
}
}
impl Serialize for Capture {
/// Flattened into its event, so the four fields sit where they always have. This is where the
/// hashing, redaction and base64 of every preview nash sends actually happen.
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(4))?;
match self {
// A range left unresolved would mean the flusher skipped it; report it as the
// metadata-only event it effectively is rather than inventing a payload.
Self::None | Self::Deferred { .. } => {
map.serialize_entry("bytes", &0u64)?;
map.serialize_entry("truncated", &false)?;
map.serialize_entry("hash", "")?;
map.serialize_entry("previewB64", "")?;
}
Self::Bytes {
bytes,
truncated,
data,
} => {
map.serialize_entry("bytes", bytes)?;
map.serialize_entry("truncated", truncated)?;
// Over the captured prefix: the full stream is never buffered for a pipe, and this
// is a did-these-bytes-repeat marker rather than an integrity claim (NASH.md §5.2).
map.serialize_entry("hash", &fnv1a_hex(data))?;
map.serialize_entry("previewB64", &preview_b64(data))?;
}
Self::Counted { bytes, hash } => {
map.serialize_entry("bytes", bytes)?;
map.serialize_entry("truncated", &true)?;
map.serialize_entry("hash", hash)?;
map.serialize_entry("previewB64", "")?;
}
}
map.end()
}
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")] #[serde(tag = "kind", rename_all = "camelCase")]
enum Event { enum Event {
@@ -104,19 +238,17 @@ enum Event {
op: String, op: String,
fd: u32, fd: u32,
target: Option<String>, target: Option<String>,
bytes: u64, /// Usually [`Capture::Deferred`] when it goes into the channel and [`Capture::Bytes`] by
truncated: bool, /// the time it is serialized — the file read happens on the flusher (§P3.2).
hash: String, #[serde(flatten)]
preview_b64: String, capture: Capture,
}, },
#[serde(rename = "cmdsub", rename_all = "camelCase")] #[serde(rename = "cmdsub", rename_all = "camelCase")]
Cmdsub { Cmdsub {
seq: u64, seq: u64,
ts: u64, ts: u64,
bytes: u64, #[serde(flatten)]
truncated: bool, capture: Capture,
hash: String,
preview_b64: String,
}, },
#[serde(rename = "pipe", rename_all = "camelCase")] #[serde(rename = "pipe", rename_all = "camelCase")]
Pipe { Pipe {
@@ -128,12 +260,15 @@ enum Event {
/// The commands either side of this link, as written — brush-core renders them from the /// The commands either side of this link, as written — brush-core renders them from the
/// AST at spawn, so they name a compound stage correctly and arrive with the very first /// AST at spawn, so they name a compound stage correctly and arrive with the very first
/// link rather than waiting on the stages to exit. /// link rather than waiting on the stages to exit.
///
/// Stage text is source, so it can carry a literal credential the same way argv can;
/// masking runs at serialization time like every other outbound string (NASH.md §5.4).
#[serde(serialize_with = "serialize_redacted")]
from_text: String, from_text: String,
#[serde(serialize_with = "serialize_redacted")]
to_text: String, to_text: String,
bytes: u64, #[serde(flatten)]
truncated: bool, capture: Capture,
hash: String,
preview_b64: String,
}, },
#[serde(rename = "dropped", rename_all = "camelCase")] #[serde(rename = "dropped", rename_all = "camelCase")]
Dropped { seq: u64, ts: u64, count: u64 }, Dropped { seq: u64, ts: u64, count: u64 },
@@ -406,26 +541,48 @@ fn looks_secret(word: &str) -> bool {
false false
} }
/// The captured (bytes-total, capped-preview, truncated) for one redirect record. /// What one redirect record captured, measured on the command's own path but **not yet read**
fn read_back(record: &brush_core::gate::RedirectRecord) -> Option<(u64, Vec<u8>, bool)> { /// (docs/NASH_STREAM_PERF_PLAN.md §P3.2).
///
/// The split is deliberate. The *range* — is this a regular file at all, and which bytes did this
/// command put there — is only true at the moment the command exited: `echo a > f; echo b >> f`
/// would otherwise have the truncating write report both lines, because by the time a batch
/// flushes the file has grown. So the exit path pays one `stat` and nothing else; opening the
/// file, reading up to 64 KiB of it, hashing, redacting and base64-encoding all move to the
/// flusher, which is where the bytes were always going anyway.
fn measure_readback(record: &brush_core::gate::RedirectRecord) -> Capture {
use brush_core::gate::RedirectReadback; use brush_core::gate::RedirectReadback;
if let Some(inline) = &record.inline { if let Some(inline) = &record.inline {
// Heredoc / here-string: the body was known at setup, so there is nothing to read.
let full = inline.as_bytes(); let full = inline.as_bytes();
let capped = full.len().min(PREVIEW_CAP); return Capture::from_bytes(full.len() as u64, full);
return Some((full.len() as u64, full[..capped].to_vec(), full.len() > capped));
} }
let path = record.path.as_ref()?; let Some(path) = record.path.as_ref() else {
return Capture::None;
};
// Only read back regular files; skip /dev/null, ttys, fifos, sockets, devices. // Only read back regular files; skip /dev/null, ttys, fifos, sockets, devices.
let meta = std::fs::metadata(path).ok()?; let Ok(meta) = std::fs::metadata(path) else {
return Capture::None;
};
if !meta.is_file() { if !meta.is_file() {
return None; return Capture::None;
} }
let size = meta.len(); let size = meta.len();
let (offset, total) = match record.readback { let (offset, total) = match record.readback {
RedirectReadback::Append => (record.size_before, size.saturating_sub(record.size_before)), RedirectReadback::Append => (record.size_before, size.saturating_sub(record.size_before)),
RedirectReadback::Truncate | RedirectReadback::Input => (0, size), RedirectReadback::Truncate | RedirectReadback::Input => (0, size),
RedirectReadback::Inline => return None, RedirectReadback::Inline => return Capture::None,
}; };
Capture::Deferred {
path: path.clone(),
offset,
total,
}
}
/// Read a measured range back, capped to [`PREVIEW_CAP`]. Returns the range's full length (which
/// may exceed what was read) and the bytes. Runs on the flusher thread only.
fn read_range(path: &Path, offset: u64, total: u64) -> Option<(u64, Vec<u8>)> {
let mut file = std::fs::File::open(path).ok()?; let mut file = std::fs::File::open(path).ok()?;
if offset > 0 { if offset > 0 {
use std::io::Seek; use std::io::Seek;
@@ -435,7 +592,7 @@ fn read_back(record: &brush_core::gate::RedirectRecord) -> Option<(u64, Vec<u8>,
let mut buf = vec![0u8; want]; let mut buf = vec![0u8; want];
let n = std::io::Read::read(&mut file, &mut buf).unwrap_or(0); let n = std::io::Read::read(&mut file, &mut buf).unwrap_or(0);
buf.truncate(n); buf.truncate(n);
Some((total, buf, total > n as u64)) Some((total, buf))
} }
/// Resolve a redirection target against the directory its command ran in, so the /// Resolve a redirection target against the directory its command ran in, so the
@@ -471,9 +628,22 @@ fn preview_b64(data: &[u8]) -> String {
let redacted = redact(&String::from_utf8_lossy(data)); let redacted = redact(&String::from_utf8_lossy(data));
b64(redacted.as_bytes()) b64(redacted.as_bytes())
} else { } else {
let hex: String = data.iter().take(1024).map(|b| format!("{b:02x}")).collect(); let mut hex = String::with_capacity(1024 * 2 + 9);
b64(format!("<binary> {hex}").as_bytes()) hex.push_str("<binary> ");
for byte in data.iter().take(1024) {
hex.push(HEX[usize::from(byte >> 4)] as char);
hex.push(HEX[usize::from(byte & 0xf)] as char);
} }
b64(hex.as_bytes())
}
}
const HEX: &[u8; 16] = b"0123456789abcdef";
/// Mask a string on its way out (docs/NASH.md §5.4). Used where redaction is deferred to
/// serialization — the flusher thread — rather than done where the string was captured.
fn serialize_redacted<S: serde::Serializer>(text: &str, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&redact(text))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -570,7 +740,8 @@ impl brush_core::gate::Gate for RecordingGate {
pipeline: pending.pipeline.map(PipelineRef::from), pipeline: pending.pipeline.map(PipelineRef::from),
}); });
// Data-flow read-back for this command's redirects (docs/NASH.md §5.2). // Data-flow read-back for this command's redirects (docs/NASH.md §5.2). Only the
// *range* is settled here (one stat); the read and the encoding ride to the flusher.
for record in &pending.redirects { for record in &pending.redirects {
// The host resolves a redirect target against the session's worktree to decide // The host resolves a redirect target against the session's worktree to decide
// which file was edited (and which lock that is), so report the path absolutely: // which file was edited (and which lock that is), so report the path absolutely:
@@ -580,8 +751,6 @@ impl brush_core::gate::Gate for RecordingGate {
.path .path
.as_ref() .as_ref()
.map(|p| absolute_path(p, &pending.cwd).to_string_lossy().into_owned()); .map(|p| absolute_path(p, &pending.cwd).to_string_lossy().into_owned());
let Some((total, data, truncated)) = read_back(record) else {
// Special file / unreadable — emit metadata only.
obs.send(Event::Redirect { obs.send(Event::Redirect {
seq: obs.seq(), seq: obs.seq(),
ts, ts,
@@ -589,24 +758,7 @@ impl brush_core::gate::Gate for RecordingGate {
op: record.op.clone(), op: record.op.clone(),
fd: record.fd, fd: record.fd,
target, target,
bytes: 0, capture: measure_readback(record),
truncated: false,
hash: String::new(),
preview_b64: String::new(),
});
continue;
};
obs.send(Event::Redirect {
seq: obs.seq(),
ts,
cmd_seq: exec_seq,
op: record.op.clone(),
fd: record.fd,
target,
bytes: total,
truncated,
hash: fnv1a_hex(&data),
preview_b64: preview_b64(&data),
}); });
} }
})); }));
@@ -616,14 +768,13 @@ impl brush_core::gate::Gate for RecordingGate {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(obs) = OBSERVER.get() else { return }; let Some(obs) = OBSERVER.get() else { return };
let full = output.as_bytes(); let full = output.as_bytes();
let capped = full.len().min(PREVIEW_CAP); // Copies the capped prefix and nothing else: this runs on the interpreter thread, in
// the middle of the expansion the shell is waiting on, and `$(cat big-file)` is a
// substitution whose *whole* result used to be hashed here (§P3.1).
obs.send(Event::Cmdsub { obs.send(Event::Cmdsub {
seq: obs.seq(), seq: obs.seq(),
ts: now_millis(), ts: now_millis(),
bytes: full.len() as u64, capture: Capture::from_bytes(full.len() as u64, full),
truncated: full.len() > capped,
hash: fnv1a_hex(full),
preview_b64: preview_b64(&full[..capped]),
}); });
})); }));
} }
@@ -632,21 +783,21 @@ impl brush_core::gate::Gate for RecordingGate {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(obs) = OBSERVER.get() else { return }; let Some(obs) = OBSERVER.get() else { return };
let capped = ev.captured.len().min(PREVIEW_CAP); let capped = ev.captured.len().min(PREVIEW_CAP);
let mut data = ev.captured;
data.truncate(capped);
obs.send(Event::Pipe { obs.send(Event::Pipe {
seq: obs.seq(), seq: obs.seq(),
ts: now_millis(), ts: now_millis(),
pipeline_id: ev.pipeline_id, pipeline_id: ev.pipeline_id,
from_index: ev.from_index as u64, from_index: ev.from_index as u64,
to_index: ev.to_index as u64, to_index: ev.to_index as u64,
// Stage text is source, so it can carry a literal credential the same way argv from_text: ev.from_text,
// can — every outbound string goes through the same masking (docs/NASH.md §5.4). to_text: ev.to_text,
from_text: redact(&ev.from_text), capture: Capture::Bytes {
to_text: redact(&ev.to_text),
bytes: ev.total_bytes, bytes: ev.total_bytes,
truncated: ev.truncated || ev.captured.len() > capped, truncated: ev.truncated || ev.total_bytes > capped as u64,
// Hash over the captured prefix (the full stream isn't buffered). data,
hash: fnv1a_hex(&ev.captured), },
preview_b64: preview_b64(&ev.captured[..capped]),
}); });
})); }));
} }
@@ -660,8 +811,12 @@ impl brush_core::gate::Gate for RecordingGate {
static UNAUTHORIZED: AtomicBool = AtomicBool::new(false); static UNAUTHORIZED: AtomicBool = AtomicBool::new(false);
fn http_request(path: &str, token: Option<&str>, body: &[u8]) -> Vec<u8> { fn http_request(path: &str, token: Option<&str>, body: &[u8]) -> Vec<u8> {
// Keep-alive, deliberately (docs/NASH_STREAM_PERF_PLAN.md §P4.1): a shell posts a batch every
// 500 ms for as long as it lives, and `Connection: close` made every one of them a fresh
// connect + accept + teardown on both sides. The host's route already answers with
// `Connection: keep-alive` and a `Content-Length`, which is what makes reuse framable.
let mut req = format!( let mut req = format!(
"POST {path} HTTP/1.1\r\nHost: nucleic\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", "POST {path} HTTP/1.1\r\nHost: nucleic\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n",
body.len() body.len()
); );
if let Some(token) = token { if let Some(token) = token {
@@ -673,55 +828,185 @@ fn http_request(path: &str, token: Option<&str>, body: &[u8]) -> Vec<u8> {
bytes bytes
} }
fn read_status<R: Read>(mut stream: R) -> Option<u16> { /// The outcome of one post on a live connection.
let mut buf = [0u8; 64]; enum Posted {
let n = stream.read(&mut buf).ok()?; /// The host took the batch and the connection can carry another.
let text = String::from_utf8_lossy(&buf[..n]); Ok,
let status = text.split_whitespace().nth(1)?; /// The host took the batch but the connection cannot be reused (no `Content-Length` to frame
status.parse().ok() /// the next response by, or the host asked to close).
OkAndClose,
/// The batch was not delivered; the connection is dropped. `retry` is false when a fresh
/// connection would not help (an outright refusal), true for an I/O failure — most often a
/// kept-open socket the host has since closed, which is nobody's fault and worth one retry.
Failed { retry: bool },
} }
fn check_status(status: Option<u16>) -> Result<(), ()> { /// Anything the transport can post over: the unix socket and the TCP fallback both qualify.
match status { trait Wire: Read + Write {}
Some(code) if (200..300).contains(&code) => Ok(()), impl<T: Read + Write> Wire for T {}
Some(401) => {
UNAUTHORIZED.store(true, Ordering::Relaxed); /// A connection kept open across batches (docs/NASH_STREAM_PERF_PLAN.md §P4.1). Lives on the
/// flusher thread, which is the only thing that posts, so it needs no locking.
#[derive(Default)]
struct Connection {
/// The live connection and the request path the transport it came from wants.
open: Option<(Box<dyn Wire>, String)>,
}
impl Connection {
/// Post one batch, reconnecting once if the kept-open connection turned out to be dead.
fn post(&mut self, cfg: &Config, body: &[u8]) -> Result<(), ()> {
for _ in 0..2 {
if self.open.is_none() {
self.open = connect(cfg);
}
let Some((stream, path)) = self.open.as_mut() else {
return Err(()); // nothing to connect to; the caller spools
};
let request = http_request(path, cfg.token.as_deref(), body);
match post_on(stream.as_mut(), &request) {
Posted::Ok => return Ok(()),
Posted::OkAndClose => {
self.open = None;
return Ok(());
}
Posted::Failed { retry } => {
self.open = None;
if !retry {
return Err(());
}
}
}
}
Err(()) Err(())
} }
// Treat unreadable/other responses as delivered-at-best-effort: the }
// host 202s known tokens, so anything else is not retryable.
Some(_) | None => Err(()), /// Open the configured transport: the unix socket first, then the TCP hook.
fn connect(cfg: &Config) -> Option<(Box<dyn Wire>, String)> {
if let Some(socket) = &cfg.socket {
if let Ok(stream) = std::os::unix::net::UnixStream::connect(socket) {
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
return Some((Box::new(stream), "/shell-event".to_string()));
}
}
let url = cfg.hook_url.as_ref()?;
let addr = std::net::ToSocketAddrs::to_socket_addrs(&(url.host.as_str(), url.port))
.ok()?
.next()?;
let stream = std::net::TcpStream::connect_timeout(&addr, IO_TIMEOUT).ok()?;
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
// Batches are one write each and latency is not what this path optimizes, but a delayed ACK
// waiting on Nagle would hold the *response* — and with it the next batch — for milliseconds.
let _ = stream.set_nodelay(true);
Some((Box::new(stream), url.path.clone()))
}
/// Write one request and read its whole response, so the connection is left framed for the next.
fn post_on(stream: &mut dyn Wire, request: &[u8]) -> Posted {
if stream.write_all(request).is_err() {
return Posted::Failed { retry: true };
}
let Some(response) = read_response(stream) else {
return Posted::Failed { retry: true };
};
match response.status {
401 => {
UNAUTHORIZED.store(true, Ordering::Relaxed);
Posted::Failed { retry: false }
}
code if (200..300).contains(&code) => {
if response.reusable {
Posted::Ok
} else {
Posted::OkAndClose
}
}
// The host 202s known tokens, so anything else is not retryable.
_ => Posted::Failed { retry: false },
} }
} }
fn post_unix(socket: &Path, token: Option<&str>, body: &[u8]) -> Result<(), ()> { struct Response {
let stream = std::os::unix::net::UnixStream::connect(socket).map_err(|_| ())?; status: u16,
let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); /// Whether the body was fully consumed and the peer means to keep the connection.
let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); reusable: bool,
let mut stream = stream;
stream
.write_all(&http_request("/shell-event", token, body))
.map_err(|_| ())?;
check_status(read_status(&mut stream))
} }
fn post_tcp(url: &HookUrl, token: Option<&str>, body: &[u8]) -> Result<(), ()> { /// Read a response's head, then drain exactly its `Content-Length` body — the whole point being
let addr = (url.host.as_str(), url.port); /// that the next response starts where this one ends. A response we cannot frame (no length,
let stream = std::net::TcpStream::connect_timeout( /// chunked, `Connection: close`) is still reported, but its connection is not reused.
&std::net::ToSocketAddrs::to_socket_addrs(&addr) fn read_response(stream: &mut dyn Read) -> Option<Response> {
.ok() let mut buf = Vec::with_capacity(256);
.and_then(|mut a| a.next()) let mut chunk = [0u8; 512];
.ok_or(())?, let head_end = loop {
IO_TIMEOUT, if let Some(at) = find_headers_end(&buf) {
) break at;
.map_err(|_| ())?; }
let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); // A response head this long is not one of ours; give up rather than read forever.
let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); if buf.len() > 8192 {
let mut stream = stream; return None;
stream }
.write_all(&http_request(&url.path, token, body)) match stream.read(&mut chunk) {
.map_err(|_| ())?; Ok(0) => return None,
check_status(read_status(&mut stream)) Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => (),
Err(_) => return None,
}
};
let head = String::from_utf8_lossy(&buf[..head_end]);
let mut lines = head.split("\r\n");
let status: u16 = lines.next()?.split_whitespace().nth(1)?.parse().ok()?;
let mut length: Option<usize> = None;
let mut close = false;
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
match name.trim().to_ascii_lowercase().as_str() {
"content-length" => length = value.trim().parse().ok(),
"connection" => close = value.trim().eq_ignore_ascii_case("close"),
_ => (),
}
}
let Some(length) = length else {
return Some(Response {
status,
reusable: false,
});
};
// Drain the body so the socket is positioned at the next response.
let mut remaining = length.saturating_sub(buf.len() - (head_end + 4));
while remaining > 0 {
let want = remaining.min(chunk.len());
match stream.read(&mut chunk[..want]) {
Ok(0) => {
return Some(Response {
status,
reusable: false,
})
}
Ok(n) => remaining -= n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => (),
Err(_) => {
return Some(Response {
status,
reusable: false,
})
}
}
}
Some(Response {
status,
reusable: !close,
})
}
/// Offset of the `\r\n\r\n` that ends a response head.
fn find_headers_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
} }
fn spool(dir: &Path, shell_id: &str, body: &[u8]) { fn spool(dir: &Path, shell_id: &str, body: &[u8]) {
@@ -733,20 +1018,10 @@ fn spool(dir: &Path, shell_id: &str, body: &[u8]) {
} }
} }
fn deliver(cfg: &Config, shell_id: &str, body: &[u8]) { fn deliver(cfg: &Config, shell_id: &str, conn: &mut Connection, body: &[u8]) {
let token = cfg.token.as_deref(); if !UNAUTHORIZED.load(Ordering::Relaxed) && conn.post(cfg, body).is_ok() {
if !UNAUTHORIZED.load(Ordering::Relaxed) {
if let Some(socket) = &cfg.socket {
if post_unix(socket, token, body).is_ok() {
return; return;
} }
}
if let Some(url) = &cfg.hook_url {
if post_tcp(url, token, body).is_ok() {
return;
}
}
}
if let Some(dir) = &cfg.spool { if let Some(dir) = &cfg.spool {
spool(dir, shell_id, body); spool(dir, shell_id, body);
} }
@@ -806,14 +1081,46 @@ fn sweep_running() -> Sweep {
sweep sweep
} }
/// The captured half of an event, for the flusher's two passes over a batch.
fn event_capture(event: &mut Event) -> Option<&mut Capture> {
match event {
Event::Redirect { capture, .. }
| Event::Cmdsub { capture, .. }
| Event::Pipe { capture, .. } => Some(capture),
_ => None,
}
}
/// Settle a batch's payloads before it is serialized — the flusher-thread half of §P3/§P4.2.
///
/// Two passes, in this order: read back every deferred file range, then spend the per-batch
/// preview budget over what that leaves. Events keep their place and their counts either way; only
/// previews are ever given up, oldest kept first, so a storm of huge pipes loses its tail rather
/// than the whole batch losing its head.
fn settle(events: &mut [Event]) {
let mut spent = 0usize;
for event in events {
let Some(capture) = event_capture(event) else {
continue;
};
capture.resolve();
spent = spent.saturating_add(capture.preview_len());
if spent > BATCH_PREVIEW_BUDGET {
capture.count_only();
}
}
}
fn flusher(cfg: Config, shell_id: String, parent_shell: Option<String>, rx: mpsc::Receiver<Msg>) { fn flusher(cfg: Config, shell_id: String, parent_shell: Option<String>, rx: mpsc::Receiver<Msg>) {
let mut buf: Vec<Event> = Vec::new(); let mut buf: Vec<Event> = Vec::new();
let mut first_at: Option<Instant> = None; let mut first_at: Option<Instant> = None;
let mut conn = Connection::default();
let flush = |buf: &mut Vec<Event>| { let flush = |buf: &mut Vec<Event>, conn: &mut Connection| {
if buf.is_empty() { if buf.is_empty() {
return; return;
} }
settle(buf);
let batch = Batch { let batch = Batch {
batch_type: "shell-batch", batch_type: "shell-batch",
source: "nash", source: "nash",
@@ -826,7 +1133,7 @@ fn flusher(cfg: Config, shell_id: String, parent_shell: Option<String>, rx: mpsc
events: buf, events: buf,
}; };
if let Ok(body) = serde_json::to_vec(&batch) { if let Ok(body) = serde_json::to_vec(&batch) {
deliver(&cfg, &shell_id, &body); deliver(&cfg, &shell_id, conn, &body);
} }
buf.clear(); buf.clear();
}; };
@@ -842,7 +1149,7 @@ fn flusher(cfg: Config, shell_id: String, parent_shell: Option<String>, rx: mpsc
} }
buf.extend(sweep.announce); buf.extend(sweep.announce);
if buf.len() >= FLUSH_MAX_EVENTS { if buf.len() >= FLUSH_MAX_EVENTS {
flush(&mut buf); flush(&mut buf, &mut conn);
first_at = None; first_at = None;
} }
} }
@@ -863,12 +1170,12 @@ fn flusher(cfg: Config, shell_id: String, parent_shell: Option<String>, rx: mpsc
} }
buf.push(ev); buf.push(ev);
if buf.len() >= FLUSH_MAX_EVENTS { if buf.len() >= FLUSH_MAX_EVENTS {
flush(&mut buf); flush(&mut buf, &mut conn);
first_at = None; first_at = None;
} }
} }
Ok(Msg::FlushSync(ack)) => { Ok(Msg::FlushSync(ack)) => {
flush(&mut buf); flush(&mut buf, &mut conn);
first_at = None; first_at = None;
let _ = ack.send(()); let _ = ack.send(());
} }
@@ -878,12 +1185,12 @@ fn flusher(cfg: Config, shell_id: String, parent_shell: Option<String>, rx: mpsc
Err(mpsc::RecvTimeoutError::Timeout) => { Err(mpsc::RecvTimeoutError::Timeout) => {
// A tick is just "go re-sweep"; the batch is not due yet. // A tick is just "go re-sweep"; the batch is not due yet.
if !ticking { if !ticking {
flush(&mut buf); flush(&mut buf, &mut conn);
first_at = None; first_at = None;
} }
} }
Err(mpsc::RecvTimeoutError::Disconnected) => { Err(mpsc::RecvTimeoutError::Disconnected) => {
flush(&mut buf); flush(&mut buf, &mut conn);
return; return;
} }
} }
@@ -976,3 +1283,179 @@ pub fn report_fallback(reason: &str, input: &str) {
}); });
flush_sync(); flush_sync();
} }
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Serialize one event the way the flusher does, as JSON.
fn json(event: &Event) -> serde_json::Value {
serde_json::to_value(event).expect("event serializes")
}
fn pipe(bytes: u64, data: Vec<u8>) -> Event {
Event::Pipe {
seq: 1,
ts: 1,
pipeline_id: 1,
from_index: 0,
to_index: 1,
from_text: "a".to_string(),
to_text: "b".to_string(),
capture: Capture::Bytes {
bytes,
truncated: bytes > data.len() as u64,
data,
},
}
}
/// The four payload fields sit where they always have, and an empty hash still means "nothing
/// was captured" rather than "the hash of nothing" — the host reads it that way.
#[test]
fn capture_serializes_the_wire_shape() {
let event = json(&pipe(5, b"hello".to_vec()));
assert_eq!(event["kind"], "pipe");
assert_eq!(event["bytes"], 5);
assert_eq!(event["truncated"], false);
assert_eq!(event["previewB64"], b64(b"hello"));
assert_eq!(event["hash"], fnv1a_hex(b"hello"));
let empty = json(&Event::Cmdsub {
seq: 1,
ts: 1,
capture: Capture::None,
});
assert_eq!(empty["bytes"], 0);
assert_eq!(empty["hash"], "");
assert_eq!(empty["previewB64"], "");
}
/// Redaction and base64 run at serialization time now, so they must still actually run —
/// on the preview *and* on the stage labels a link carries.
#[test]
fn serialization_redacts_previews_and_stage_text() {
let secret = "ghp_0123456789abcdefghij";
let event = json(&Event::Pipe {
seq: 1,
ts: 1,
pipeline_id: 1,
from_index: 0,
to_index: 1,
from_text: format!("curl -H {secret}"),
to_text: "sh".to_string(),
capture: Capture::from_bytes(secret.len() as u64, secret.as_bytes()),
});
assert!(!event["fromText"].as_str().unwrap().contains(secret));
assert_eq!(event["fromText"], "curl -H «redacted»");
assert_eq!(event["previewB64"], b64("«redacted»".as_bytes()));
}
/// A capture is capped to the preview cap where it is taken, and says so.
#[test]
fn capture_caps_and_marks_truncation() {
let big = vec![b'x'; PREVIEW_CAP + 10];
let Capture::Bytes {
bytes,
truncated,
data,
} = Capture::from_bytes(big.len() as u64, &big)
else {
panic!("expected captured bytes");
};
assert_eq!(bytes, big.len() as u64);
assert_eq!(data.len(), PREVIEW_CAP);
assert!(truncated);
}
/// The per-batch budget (docs/NASH.md §5.4): once a batch has carried its allowance of
/// payload, later events keep their counts and hashes and give up their previews.
#[test]
fn batch_preview_budget_drops_previews_not_events() {
let payload = vec![b'z'; PREVIEW_CAP];
let over = BATCH_PREVIEW_BUDGET / PREVIEW_CAP + 2;
let mut events: Vec<Event> = (0..over)
.map(|_| pipe(PREVIEW_CAP as u64, payload.clone()))
.collect();
settle(&mut events);
let rendered: Vec<serde_json::Value> = events.iter().map(json).collect();
// Every event survives — only previews are given up, and the earliest keep theirs.
assert_eq!(rendered.len(), over);
assert_ne!(rendered[0]["previewB64"], "");
let last = rendered.last().unwrap();
assert_eq!(last["previewB64"], "");
assert_eq!(last["bytes"], PREVIEW_CAP);
assert_eq!(last["hash"], fnv1a_hex(&payload));
assert_eq!(last["truncated"], true);
let kept = rendered
.iter()
.filter(|e| e["previewB64"] != "")
.count();
assert_eq!(kept, BATCH_PREVIEW_BUDGET / PREVIEW_CAP);
}
/// A redirect's bytes are read by the flusher, from the range measured when the command
/// exited — an append that lands afterwards must not widen it.
#[test]
fn deferred_readback_reads_the_measured_range() {
let dir = std::env::temp_dir().join(format!("nash-observe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("out.txt");
std::fs::write(&path, b"first\n").unwrap();
let mut event = Event::Redirect {
seq: 1,
ts: 1,
cmd_seq: 1,
op: ">".to_string(),
fd: 1,
target: Some(path.to_string_lossy().into_owned()),
capture: Capture::Deferred {
path: path.clone(),
offset: 0,
total: 6,
},
};
// The file grows before the batch flushes; the event still reports what its command wrote.
std::fs::write(&path, b"first\nsecond\n").unwrap();
settle(std::slice::from_mut(&mut event));
let rendered = json(&event);
assert_eq!(rendered["bytes"], 6);
assert_eq!(rendered["previewB64"], b64(b"first\n"));
assert_eq!(rendered["truncated"], false);
// A target that has since vanished degrades to metadata only rather than failing.
std::fs::remove_file(&path).unwrap();
let mut gone = Event::Redirect {
seq: 2,
ts: 1,
cmd_seq: 1,
op: ">".to_string(),
fd: 1,
target: None,
capture: Capture::Deferred {
path,
offset: 0,
total: 6,
},
};
settle(std::slice::from_mut(&mut gone));
assert_eq!(json(&gone)["bytes"], 0);
assert_eq!(json(&gone)["hash"], "");
let _ = std::fs::remove_dir_all(&dir);
}
/// Binary payloads are previewed as a hex head (now table-driven, §P3.3) — same bytes as the
/// `format!`-per-byte encoding it replaced.
#[test]
fn binary_previews_as_hex() {
let preview = preview_b64(&[0xff, 0x00, 0x0a]);
assert_eq!(preview, b64(b"<binary> ff000a"));
}
}
+86 -10
View File
@@ -48,6 +48,9 @@ fn scratch_dir(prefix: &str) -> std::path::PathBuf {
struct Sink { struct Sink {
dir: std::path::PathBuf, dir: std::path::PathBuf,
rx: mpsc::Receiver<(Option<String>, serde_json::Value)>, rx: mpsc::Receiver<(Option<String>, serde_json::Value)>,
/// Connections accepted so far — how the keep-alive test tells one reused connection from
/// one per batch (docs/NASH_STREAM_PERF_PLAN.md §P4.1).
connections: std::sync::Arc<AtomicU32>,
} }
impl Sink { impl Sink {
@@ -59,37 +62,52 @@ impl Sink {
let listener = UnixListener::bind(&socket_path) let listener = UnixListener::bind(&socket_path)
.unwrap_or_else(|e| panic!("bind {} ({} bytes): {e}", socket_path.display(), socket_path.as_os_str().len())); .unwrap_or_else(|e| panic!("bind {} ({} bytes): {e}", socket_path.display(), socket_path.as_os_str().len()));
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let connections = std::sync::Arc::new(AtomicU32::new(0));
let accepted = std::sync::Arc::clone(&connections);
std::thread::spawn(move || { std::thread::spawn(move || {
for stream in listener.incoming() { for stream in listener.incoming() {
let Ok(mut stream) = stream else { break }; let Ok(mut stream) = stream else { break };
accepted.fetch_add(1, Ordering::Relaxed);
let tx = tx.clone(); let tx = tx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let mut buf = Vec::new(); let mut buf = Vec::new();
let mut chunk = [0u8; 4096]; let mut chunk = [0u8; 4096];
// Read until headers + declared body length are complete. // Read until headers + declared body length are complete, answer, and keep
// reading: nash reuses one connection for every batch of a shell's life
// (docs/NASH_STREAM_PERF_PLAN.md §P4.1), exactly as the host's route does.
loop { loop {
match stream.read(&mut chunk) { if let Some(request) = request_len(&buf) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if let Some(body) = full_body(&buf) {
let auth = header(&buf, "authorization"); let auth = header(&buf, "authorization");
if let Some(body) = full_body(&buf) {
if let Ok(json) = serde_json::from_slice(body) { if let Ok(json) = serde_json::from_slice(body) {
let _ = tx.send((auth, json)); let _ = tx.send((auth, json));
} }
let _ = stream.write_all( }
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n", if stream
); .write_all(
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n",
)
.is_err()
{
break; break;
} }
buf.drain(..request);
continue;
} }
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(_) => break, Err(_) => break,
} }
} }
}); });
} }
}); });
Self { dir, rx } Self {
dir,
rx,
connections,
}
} }
fn socket(&self) -> std::path::PathBuf { fn socket(&self) -> std::path::PathBuf {
@@ -148,6 +166,14 @@ impl Drop for Sink {
} }
} }
/// Total bytes of the first complete request in `buf` (head + body), or None while it is still
/// arriving — what lets one connection carry the next batch after this one is answered.
fn request_len(buf: &[u8]) -> Option<usize> {
let headers_end = buf.windows(4).position(|w| w == b"\r\n\r\n")? + 4;
let len: usize = header(buf, "content-length")?.parse().ok()?;
(buf.len() >= headers_end + len).then_some(headers_end + len)
}
fn full_body(buf: &[u8]) -> Option<&[u8]> { fn full_body(buf: &[u8]) -> Option<&[u8]> {
let headers_end = buf.windows(4).position(|w| w == b"\r\n\r\n")? + 4; let headers_end = buf.windows(4).position(|w| w == b"\r\n\r\n")? + 4;
let len: usize = header(buf, "content-length")?.parse().ok()?; let len: usize = header(buf, "content-length")?.parse().ok()?;
@@ -869,3 +895,53 @@ fn capture_off_silences_nash_without_a_policy() {
"capture=off with no policy posts nothing" "capture=off with no policy posts nothing"
); );
} }
/// A shell posts a batch every 500 ms for as long as it lives, so the connection is opened once
/// and kept (docs/NASH_STREAM_PERF_PLAN.md §P4.1) rather than reconnected per batch. Two commands
/// either side of a flush window are two batches; one connection has to carry both.
#[test]
fn one_connection_carries_every_batch() {
let sink = Sink::start();
let status = run_nash(&sink, "echo one; sleep 0.9; echo two");
assert_eq!(status.code(), Some(0));
let events = sink.events_until(|evs| {
find(evs, "exec", |e| e["argv"][0] == "echo" && e["argv"][1] == "two").is_some()
});
// Both commands were reported…
assert!(find(&events, "exec", |e| e["argv"][1] == "one").is_some());
assert!(find(&events, "exec", |e| e["argv"][1] == "two").is_some());
// …and the shell only ever dialled the sink once.
assert_eq!(
sink.connections.load(Ordering::Relaxed),
1,
"batches must share one kept-open connection"
);
}
/// The budget's end-to-end half (docs/NASH.md §5.4): a burst of fat pipes in one batch keeps every
/// event and every byte count, and gives up only the previews past the allowance.
#[test]
fn batch_preview_budget_keeps_counts_and_drops_previews() {
let sink = Sink::start();
// 24 links × 64 KiB of payload each — comfortably past the 1 MiB per-batch preview budget,
// and fast enough that they all land in one 500 ms flush.
let status = run_nash(
&sink,
"for i in $(seq 1 24); do head -c 65536 /dev/zero | cat > /dev/null; done",
);
assert_eq!(status.code(), Some(0));
let events = sink.events_until(|evs| evs.iter().filter(|e| e["kind"] == "pipe").count() >= 24);
let pipes: Vec<_> = events.iter().filter(|e| e["kind"] == "pipe").collect();
assert!(pipes.len() >= 24, "every link is still reported: {}", pipes.len());
// Nothing loses its count or its fingerprint…
for pipe in &pipes {
assert_eq!(pipe["bytes"], 65536);
assert_ne!(pipe["hash"], "");
}
// …and the ones past the budget are the ones without a preview.
let dropped = pipes.iter().filter(|p| p["previewB64"] == "").count();
assert!(dropped > 0, "the budget dropped no previews");
assert!(dropped < pipes.len(), "the budget dropped every preview");
}
+257 -37
View File
@@ -1999,52 +1999,159 @@ fn nash_stage_text(pipeline: &ast::Pipeline, index: usize) -> String {
text text
} }
// nash: spawn a copier that moves bytes from `src` (producer's output) to `dst` // nash: bytes moved per userspace copy on the portable tee path. 128 KiB rather than the 32 KiB
// (consumer's input), mirroring a bounded prefix, then reports the link to the // this started with: the same stream costs a quarter of the read/write pairs, and a pipe link's
// gate (docs/NASH.md §5.3). The synchronous copy preserves pipe backpressure; // tee cost is almost entirely syscalls and the context switches around them
// EOF on `src` or EPIPE on `dst` (consumer gone, e.g. `yes | head`) ends it and // (docs/NASH_STREAM_PERF_PLAN.md §P2.2).
// closes `dst` so the consumer sees EOF. const NASH_TEE_BUF: usize = 128 * 1024;
// nash: how large both ends of a tapped link are grown to, where the platform allows it. A tee
// puts *two* pipes where the shell asked for one, so a producer that outruns the default 64 KiB
// buffer pays for it twice; growing them cuts the wakeups on both sides.
#[cfg(target_os = "linux")]
const NASH_TEE_PIPE_SIZE: libc::c_int = 256 * 1024;
// nash: bytes asked of one `splice` call. The kernel moves at most a pipe-buffer's worth per call
// regardless, so this only has to be comfortably larger than the pipe.
#[cfg(target_os = "linux")]
const NASH_TEE_SPLICE_CHUNK: usize = 1024 * 1024;
// nash: how many finished tee threads stay parked waiting for the next pipeline. Thread creation
// is the bulk of the ~0.13 ms a tapped pipeline costs to *set up*, and shell-heavy builds run
// pipelines in the thousands (docs/NASH_STREAM_PERF_PLAN.md §P2.3). A shell runs its pipelines
// mostly one at a time, so a small pool covers the common case; a burst of concurrent pipelines
// simply spawns beyond it and lets the extra workers retire when they finish.
const NASH_TEE_POOL_MAX_IDLE: usize = 4;
// nash: stack for a tee worker. It copies through a heap buffer and hands the gate an already-built
// event, so its own frames are shallow — a quarter of the 2 MiB default is ample and maps less.
const NASH_TEE_STACK: usize = 512 * 1024;
// nash: one link waiting to be copied — what a tee worker is handed.
struct NashTeeJob {
src: std::io::PipeReader,
dst: std::io::PipeWriter,
pipeline_id: u64,
from_index: usize,
from_text: String,
to_text: String,
}
// nash: senders of the tee workers currently parked, newest last.
static NASH_TEE_POOL: std::sync::Mutex<Vec<std::sync::mpsc::Sender<NashTeeJob>>> =
std::sync::Mutex::new(Vec::new());
// nash: the process the parked workers belong to. A `fork` copies the pool's *memory* but none of
// its threads, so a child that inherited it would hand jobs to workers that do not exist — and a
// dropped job silently breaks the pipeline it was tapping. Checked (and reset) on every dispatch.
static NASH_TEE_POOL_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
// nash: take a parked worker, if this process has one to spare.
//
// `try_lock`, never `lock`: this runs on the shell's own pipeline-setup path, so waiting on the
// pool would put observation in front of a command — and after a `fork` the mutex can be left
// locked by a thread that no longer exists. Failing to take a worker just means spawning one.
fn nash_tee_take_idle() -> Option<std::sync::mpsc::Sender<NashTeeJob>> {
let mut pool = NASH_TEE_POOL.try_lock().ok()?;
let pid = std::process::id();
if NASH_TEE_POOL_PID.swap(pid, std::sync::atomic::Ordering::Relaxed) != pid {
pool.clear(); // inherited across a fork: those workers are not in this process
}
pool.pop()
}
// nash: park a worker that just finished a link. Returns whether it was taken — a worker the pool
// has no room for (or cannot reach) simply exits.
fn nash_tee_park(tx: &std::sync::mpsc::Sender<NashTeeJob>) -> bool {
let Ok(mut pool) = NASH_TEE_POOL.try_lock() else {
return false;
};
if pool.len() >= NASH_TEE_POOL_MAX_IDLE {
return false;
}
pool.push(tx.clone());
true
}
// nash: a parked worker's loop — copy a link, park again, wait for the next one.
fn nash_tee_worker(
rx: &std::sync::mpsc::Receiver<NashTeeJob>,
parked: &std::sync::mpsc::Sender<NashTeeJob>,
) {
while let Ok(job) = rx.recv() {
nash_run_pipe_tee(job);
if !nash_tee_park(parked) {
return;
}
}
}
// nash: hand a copier the link `src` (producer's output) → `dst` (consumer's input): it mirrors a
// bounded prefix, then reports the link to the gate (docs/NASH.md §5.3). The synchronous copy
// preserves pipe backpressure; EOF on `src` or EPIPE on `dst` (consumer gone, e.g. `yes | head`)
// ends it and closes `dst` so the consumer sees EOF.
// `to_index` is always `from_index + 1` — a link joins adjacent stages — so it is derived rather // `to_index` is always `from_index + 1` — a link joins adjacent stages — so it is derived rather
// than passed; `from_text`/`to_text` are the two stages already rendered by `nash_stage_text`. // than passed; `from_text`/`to_text` are the two stages already rendered by `nash_stage_text`.
fn nash_spawn_pipe_tee( fn nash_spawn_pipe_tee(
mut src: std::io::PipeReader, src: std::io::PipeReader,
mut dst: std::io::PipeWriter, dst: std::io::PipeWriter,
pipeline_id: u64, pipeline_id: u64,
from_index: usize, from_index: usize,
from_text: String, from_text: String,
to_text: String, to_text: String,
) { ) {
std::thread::Builder::new() let mut job = NashTeeJob {
src,
dst,
pipeline_id,
from_index,
from_text,
to_text,
};
// A worker that retired between being parked and being handed this job returns it, so try the
// next one down rather than losing the link.
while let Some(tx) = nash_tee_take_idle() {
match tx.send(job) {
Ok(()) => return,
Err(std::sync::mpsc::SendError(returned)) => job = returned,
}
}
let (tx, rx) = std::sync::mpsc::channel::<NashTeeJob>();
let parked = tx.clone();
// If the spawn fails there is nobody to copy this link and the job is dropped, closing both
// ends — the same outcome an unspawnable tee thread has always had, in a shell that is out of
// threads either way.
if std::thread::Builder::new()
.name("nash-pipe-tee".into()) .name("nash-pipe-tee".into())
.spawn(move || { .stack_size(NASH_TEE_STACK)
use std::io::{Read, Write}; .spawn(move || nash_tee_worker(&rx, &parked))
let mut buf = [0u8; 32 * 1024]; .is_ok()
{
let _ = tx.send(job);
}
}
// nash: copy one link through to its consumer, then report it.
fn nash_run_pipe_tee(job: NashTeeJob) {
let NashTeeJob {
mut src,
mut dst,
pipeline_id,
from_index,
from_text,
to_text,
} = job;
nash_grow_pipe(&src);
nash_grow_pipe(&dst);
let mut captured: Vec<u8> = Vec::new(); let mut captured: Vec<u8> = Vec::new();
let mut total: u64 = 0; let mut total: u64 = 0;
let mut truncated = false; // Only the prefix is ever copied through userspace; the rest of the stream — which is all of
loop { // it, for the transfers that actually cost something — is handed to the kernel.
let n = match src.read(&mut buf) { if nash_tee_prefix(&mut src, &mut dst, &mut captured, &mut total) {
Ok(0) => break, // producer closed → done nash_tee_passthrough(&mut src, &mut dst, &mut total);
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
};
total += n as u64;
if captured.len() < NASH_PIPE_CAPTURE_CAP {
let room = NASH_PIPE_CAPTURE_CAP - captured.len();
let take = room.min(n);
captured.extend_from_slice(&buf[..take]);
if take < n {
truncated = true;
}
} else {
truncated = true;
}
// Pass through; if the consumer went away, stop (its EOF is enough).
if dst.write_all(&buf[..n]).is_err() {
break;
}
} }
// Report BEFORE closing `dst`: dropping it unblocks the consumer, which // Report BEFORE closing `dst`: dropping it unblocks the consumer, which
// lets the shell reach its exit-flush — so the event must already be // lets the shell reach its exit-flush — so the event must already be
// enqueued to avoid a lost-event race on short-lived shells. // enqueued to avoid a lost-event race on short-lived shells.
@@ -2055,15 +2162,128 @@ fn nash_spawn_pipe_tee(
from_text, from_text,
to_text, to_text,
total_bytes: total, total_bytes: total,
truncated: total > captured.len() as u64,
captured, captured,
truncated,
}); });
// Now close the write end so the consumer sees EOF. // Now close the write end so the consumer sees EOF.
drop(dst); drop(dst);
})
.ok();
} }
// nash: copy until the capture cap is reached, mirroring what goes past. Returns whether the link
// is still open (false on EOF, or once either end gives up).
fn nash_tee_prefix(
src: &mut std::io::PipeReader,
dst: &mut std::io::PipeWriter,
captured: &mut Vec<u8>,
total: &mut u64,
) -> bool {
use std::io::{Read, Write};
let mut buf = vec![0u8; NASH_TEE_BUF];
while captured.len() < NASH_PIPE_CAPTURE_CAP {
let n = match src.read(&mut buf) {
Ok(0) => return false, // producer closed → done
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => return false,
};
*total += n as u64;
let room = NASH_PIPE_CAPTURE_CAP - captured.len();
captured.extend_from_slice(&buf[..room.min(n)]);
// Pass through; if the consumer went away, stop (its EOF is enough).
if dst.write_all(&buf[..n]).is_err() {
return false;
}
}
true
}
// nash: the portable passthrough — the same read/write loop as the prefix phase, minus the
// capture. Used on platforms without `splice`, and as the fallback when the kernel refuses it.
fn nash_tee_copy(src: &mut std::io::PipeReader, dst: &mut std::io::PipeWriter, total: &mut u64) {
use std::io::{Read, Write};
let mut buf = vec![0u8; NASH_TEE_BUF];
loop {
let n = match src.read(&mut buf) {
Ok(0) => return,
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => return,
};
*total += n as u64;
if dst.write_all(&buf[..n]).is_err() {
return;
}
}
}
// nash: move the rest of the link pipe-to-pipe inside the kernel (docs/NASH_STREAM_PERF_PLAN.md
// §P2.1). Past the captured prefix the tee has nothing to look at, so there is no reason for the
// bytes to enter this process at all — and the byte count comes back from the same call that
// moves them.
#[cfg(target_os = "linux")]
fn nash_tee_passthrough(
src: &mut std::io::PipeReader,
dst: &mut std::io::PipeWriter,
total: &mut u64,
) {
use std::os::fd::AsRawFd;
let (in_fd, out_fd) = (src.as_raw_fd(), dst.as_raw_fd());
loop {
// SAFETY: both descriptors are pipes owned by this job for the duration of the call, and
// the offsets are null because pipes have none.
let moved = unsafe {
libc::splice(
in_fd,
std::ptr::null_mut(),
out_fd,
std::ptr::null_mut(),
NASH_TEE_SPLICE_CHUNK,
libc::SPLICE_F_MOVE,
)
};
if moved == 0 {
return; // producer closed → done
}
if moved > 0 {
*total += u64::try_from(moved).unwrap_or(0);
continue;
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EINTR) => (),
// A kernel (or a seccomp policy) that will not splice these descriptors: finish the
// link the portable way rather than dropping it.
Some(libc::EINVAL | libc::ENOSYS | libc::EPERM) => {
nash_tee_copy(src, dst, total);
return;
}
// Consumer gone (EPIPE) or a link that broke: its EOF is enough.
_ => return,
}
}
}
#[cfg(not(target_os = "linux"))]
fn nash_tee_passthrough(
src: &mut std::io::PipeReader,
dst: &mut std::io::PipeWriter,
total: &mut u64,
) {
nash_tee_copy(src, dst, total);
}
// nash: grow a tapped pipe's buffer, best-effort. The kernel caps unprivileged growth at
// `/proc/sys/fs/pipe-max-size` and simply refuses anything larger, which costs one failed syscall
// per link and leaves the default in place.
#[cfg(target_os = "linux")]
fn nash_grow_pipe<F: std::os::fd::AsRawFd>(pipe: &F) {
// SAFETY: `fcntl` with `F_SETPIPE_SZ` takes an int and only reads the descriptor's pipe size.
let _ = unsafe { libc::fcntl(pipe.as_raw_fd(), libc::F_SETPIPE_SZ, NASH_TEE_PIPE_SIZE) };
}
#[cfg(not(target_os = "linux"))]
fn nash_grow_pipe<F>(_pipe: &F) {}
// nash: record a regular-file redirect for post-run read-back (docs/NASH.md §5.2a). // nash: record a regular-file redirect for post-run read-back (docs/NASH.md §5.2a).
// The child still gets the real fd; this only notes what to read back afterward. // The child still gets the real fd; this only notes what to read back afterward.
fn nash_record_file_redirect( fn nash_record_file_redirect(