Add a per-host running terminal limit setting (#31396)

* feat: configure the running terminal limit for each host

* fix: bound terminal policy saves through fresh confirmation

* Fix truncated zero-size system file reads

* Load actual filesystem fixtures once per test module

* Preserve slept terminal ownership through capacity retries

* Fix terminal limit CI quality checks

* Correct slept terminal resume test types

* Move the slept terminal Resume fix to its own pull request

The Resume correction fixes a bug that already exists at the default limit,
so it now lives in #35401 and this change only adds the terminal limit
setting.

* Keep terminal limits effective when saved settings cannot be read

* Require a numeric edit before repairing terminal limits

* Derive terminal default from startup host capacity

* Apply terminal policy reads when validated results settle

* Align terminal limit tests with static analysis gates

* Inject daemon startup errors at the publication boundary

* Format terminal capacity using the app language
This commit is contained in:
Brennan Benson
2026-10-29 12:18:02 -07:00
committed by GitHub
parent 6d76f5a77b
commit 91efcc4df0
46 changed files with 2177 additions and 122 deletions
@@ -10,12 +10,14 @@ import type { AutomaticWorkRequestType } from './daemon-automatic-work-protocol'
import { DaemonProcessGrowthMonitor } from './daemon-process-growth-monitor'
import { HostTerminalLedger, resolveMaxHostTerminals } from './host-terminal-ledger'
import { LegacyRestoreRecords, legacyRestoreRecordsPathFor } from './legacy-restore-records'
import type { HostTerminalPolicy } from './host-terminal-policy'
import { TerminalSpawnGate, type TerminalSpawnAdmissionMode } from './terminal-spawn-gate'
type DaemonAutomaticWorkGuardOptions = {
markerPath: string | undefined
/** Where every daemon on this host shares its terminal count; undefined caps this one alone. */
hostTerminalLedgerDir?: string | undefined
terminalPolicy?: HostTerminalPolicy
mode: TerminalSpawnAdmissionMode
log: { log: (event: string, details: Record<string, unknown>) => void }
listSessions: () => readonly { sessionId: string; pid: number | null }[]
@@ -45,6 +47,7 @@ export class DaemonAutomaticWorkGuard {
onLevelChanged: (level) => this.onPressureChanged(level),
onTrip: (trips) => this.onTrip(trips)
})
const policy = options.terminalPolicy
this.gate = new TerminalSpawnGate({
log: (event, details) => options.log.log(event, details),
mode: options.mode,
@@ -52,6 +55,7 @@ export class DaemonAutomaticWorkGuard {
automaticWorkMode: () => this.automaticWorkMode(),
armAutomaticWork: () => this.marker.arm(),
legacyRestores: this.legacyRestores,
resolveMaximum: policy ? () => policy.admissionMaximum() : undefined,
hostTerminals: new HostTerminalLedger({
dir: options.hostTerminalLedgerDir ?? null,
maxTerminals: resolveMaxHostTerminals(),
@@ -1,3 +1,4 @@
import type { TerminalLimitStatus, TerminalLimitUpdate } from '../../shared/terminal-limit-policy'
import type {
AutomaticWorkResumeOutcome,
AutomaticWorkStatus
@@ -9,7 +10,8 @@ import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
type LocalDaemons = { current: DaemonPtyAdapter; legacy: readonly DaemonPtyAdapter[] }
function localDaemons(provider: IPtyProvider): LocalDaemons | null {
/** The daemons behind the local provider; null for the in-process fallback, which has no gate. */
export function localDaemons(provider: IPtyProvider): LocalDaemons | null {
if (provider instanceof DaemonPtyAdapter) {
return { current: provider, legacy: [] }
}
@@ -42,3 +44,10 @@ export async function resumeLocalAutomaticWork(
}
return current.value
}
export async function localTerminalLimit(
provider: IPtyProvider,
update?: TerminalLimitUpdate
): Promise<TerminalLimitStatus | null> {
return (await localDaemons(provider)?.current.terminalLimit(update)) ?? null
}
+28
View File
@@ -0,0 +1,28 @@
import type { Socket } from 'node:net'
import { NOTIFY_PREFIX, type DaemonRequest } from './types'
import { encodeNdjson } from './ndjson'
export async function answerDaemonControlRequest(
socket: Socket,
request: DaemonRequest,
route: () => Promise<unknown>,
onReply: () => void
): Promise<void> {
const isNotify = request.id.startsWith(NOTIFY_PREFIX)
try {
const result = await route()
if (!isNotify) {
socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), onReply)
}
} catch (error) {
if (!isNotify) {
socket.write(
encodeNdjson({
id: request.id,
ok: false,
error: error instanceof Error ? error.message : String(error)
})
)
}
}
}
+30 -1
View File
@@ -3,13 +3,42 @@ import { existsSync } from 'node:fs'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { isDaemonGoneError, isUnknownRequestTypeError } from './daemon-endpoint-errors'
import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import type { ListSessionsResult } from './types'
import type { PtyBackgroundStreamEvent } from '../providers/pty-provider-contract'
import type { TerminalLimitStatus, TerminalLimitUpdate } from '../../shared/terminal-limit-policy'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence {
abstract getDaemonIdentity(): DaemonEndpointIdentity | null
async terminalLimit(update?: TerminalLimitUpdate): Promise<TerminalLimitStatus | null> {
await this.ensureConnected()
const identity = this.getDaemonIdentity()
if (update && identity?.launchNonce !== update.daemonNonce) {
throw new Error('Terminal host changed. Refresh the setting and try again.')
}
try {
const type = update ? 'setTerminalLimit' : 'terminalLimitStatus'
const status = await this.client.request<TerminalLimitStatus>(type, update)
if (
!identity ||
this.getDaemonIdentity()?.launchNonce !== identity.launchNonce ||
status.daemonNonce !== identity.launchNonce
) {
throw new Error('Terminal host changed. Refresh the setting and try again.')
}
return status
} catch (error) {
if (isUnknownRequestTypeError(error)) {
return null
}
throw error
}
}
// Token reads no longer throw; check absence explicitly after an authenticated disconnect.
protected isRetiredEndpointTokenMissing(): boolean {
return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath)
+13 -3
View File
@@ -16,13 +16,23 @@ export async function awaitDaemonWorkWithinCallerDeadline(
work: Promise<void>,
deadlineMs: number
): Promise<boolean> {
return (await awaitDaemonValueWithinCallerDeadline(work, deadlineMs)).completed
}
export async function awaitDaemonValueWithinCallerDeadline<T>(
work: Promise<T>,
deadlineMs: number
): Promise<{ completed: true; value: T } | { completed: false }> {
void work.catch(() => {})
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
work.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now()))
work.then((value) => ({ completed: true as const, value })),
new Promise<{ completed: false }>((resolve) => {
timer = setTimeout(
() => resolve({ completed: false }),
Math.max(1, deadlineMs - Date.now())
)
timer.unref?.()
})
])
@@ -37,6 +37,7 @@ function createMockSubprocess(): SubprocessHandle {
}
type DaemonServerInternals = {
endpoint: {
publish(bindPath: string): Promise<void>
hasLostOwnership(): boolean
requestRetirementForLoss(): void
checkOwnership(): void
@@ -435,15 +436,23 @@ describe('daemon server error handling', () => {
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
const started = server.start()
const daemon = server as unknown as DaemonServerInternals
await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true))
daemon.lifecycle.server?.emit('error', new Error('injected accept failure'))
const publish = daemon.endpoint.publish.bind(daemon.endpoint)
const publication = vi
.spyOn(daemon.endpoint, 'publish')
.mockImplementationOnce((bindPath) => {
daemon.lifecycle.server?.emit('error', new Error('injected accept failure'))
return publish(bindPath)
})
await expect(started).rejects.toThrow('injected accept failure')
// Startup rejects before publication settles; wait until the failed daemon actually stops serving.
await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull())
await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true)
try {
await expect(server.start()).rejects.toThrow('injected accept failure')
// Startup rejects before publication settles; wait until the failed daemon actually stops serving.
await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull())
await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true)
} finally {
publication.mockRestore()
}
}
)
})
+2
View File
@@ -1,6 +1,7 @@
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session-subprocess-handle'
import type { TerminalSpawnAdmissionMode } from './terminal-spawn-gate'
import type { HostTerminalPolicy } from './host-terminal-policy'
export type DaemonServerOptions = {
socketPath: string
@@ -35,6 +36,7 @@ export type DaemonServerOptions = {
automaticWorkMarkerPath?: string
/** Shared terminal counts for the host-wide cap. */
hostTerminalLedgerDir?: string
terminalLimitPolicy?: HostTerminalPolicy
spawnSubprocess: (opts: {
sessionId: string
cols: number
+37 -40
View File
@@ -23,13 +23,10 @@ import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-tra
import { TerminalHost } from './terminal-host'
import { DaemonAutomaticWorkGuard } from './daemon-automatic-work-guard'
import { answerAutomaticWorkRequest } from './daemon-automatic-work-protocol'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
NOTIFY_PREFIX,
PROTOCOL_VERSION,
type DaemonRequest
} from './types'
import { encodeNdjson } from './ndjson'
import { HostTerminalPolicy } from './host-terminal-policy'
import { answerTerminalLimitRequest } from './daemon-terminal-limit-protocol'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, type DaemonRequest } from './types'
import { answerDaemonControlRequest } from './daemon-control-request'
export class DaemonServer {
private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000
@@ -48,12 +45,16 @@ export class DaemonServer {
private readonly admission: DaemonTerminalAdmission
private readonly requestRouter: DaemonRequestRouter
private stopStreamBacklogProbe: () => void = () => {}
private readonly terminalPolicy: HostTerminalPolicy
private terminalPolicyNonce = ''
constructor(options: DaemonServerOptions) {
this.log = options.log ?? createNoopDaemonFileLog()
this.terminalPolicy = options.terminalLimitPolicy ?? new HostTerminalPolicy()
this.automaticWork = new DaemonAutomaticWorkGuard({
markerPath: options.automaticWorkMarkerPath,
hostTerminalLedgerDir: options.hostTerminalLedgerDir,
terminalPolicy: this.terminalPolicy,
mode: options.spawnAdmission ?? 'observe',
log: this.log,
listSessions: () => this.host.listSessions(),
@@ -112,14 +113,11 @@ export class DaemonServer {
)
const protocolVersion = options.protocolVersion ?? PROTOCOL_VERSION
const launchNonce =
options.launchNonce ??
(protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null)
const hasIdentity = protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION
const launchNonce = options.launchNonce ?? (hasIdentity ? randomUUID() : null)
const startedAtMs =
options.startedAtMs ??
(protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION
? Date.now() - process.uptime() * 1000
: null)
options.startedAtMs ?? (hasIdentity ? Date.now() - process.uptime() * 1000 : null)
this.terminalPolicyNonce = launchNonce ?? randomUUID()
const token = randomUUID()
this.connections = new DaemonClientConnections({
@@ -180,11 +178,7 @@ export class DaemonServer {
onOwnershipLost: () => this.lifecycle.onEndpointOwnershipLost()
})
const lifecycleClock = options.initialAdoptionTestConfig?.clock ?? {
setTimeout: (callback: () => void, delayMs: number) => {
const timer = setTimeout(callback, delayMs)
timer.unref()
return timer
},
setTimeout: (callback: () => void, delayMs: number) => setTimeout(callback, delayMs).unref(),
clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
now: () => Date.now()
}
@@ -244,7 +238,16 @@ export class DaemonServer {
}
async start(): Promise<void> {
await this.lifecycle.start((socket) => this.connections.accept(socket))
await this.terminalPolicy.start((event, details) => this.log.log(event, details))
if (!this.lifecycle.isAcceptingWork()) {
return
}
try {
await this.lifecycle.start((socket) => this.connections.accept(socket))
} catch (error) {
this.terminalPolicy.dispose()
throw error
}
// Only the endpoint winner may inspect or rewrite the automatic-work marker.
this.automaticWork.start()
}
@@ -264,6 +267,7 @@ export class DaemonServer {
}
private async disposeResources(): Promise<void> {
this.terminalPolicy.dispose()
this.endpoint.stopOwnershipWatch()
this.stopStreamBacklogProbe()
this.transientFactRelay.dispose()
@@ -289,26 +293,19 @@ export class DaemonServer {
clientId: string,
request: DaemonRequest
): Promise<void> {
const isNotify = request.id.startsWith(NOTIFY_PREFIX)
try {
const result =
return answerDaemonControlRequest(
socket,
request,
async () =>
answerTerminalLimitRequest(
request,
this.terminalPolicy,
this.terminalPolicyNonce,
() => this.lifecycle.isAcceptingWork() && !this.endpoint.hasLostOwnership()
) ??
answerAutomaticWorkRequest(request, this.automaticWork, this.endpoint) ??
(await this.requestRouter.route(clientId, request))
if (!isNotify) {
socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => {
this.lifecycle.startPendingShutdownReply(clientId, request.id)
})
}
} catch (error) {
if (!isNotify) {
socket.write(
encodeNdjson({
id: request.id,
ok: false,
error: error instanceof Error ? error.message : String(error)
})
)
}
}
this.requestRouter.route(clientId, request),
() => this.lifecycle.startPendingShutdownReply(clientId, request.id)
)
}
}
@@ -0,0 +1,39 @@
import {
isConfiguredTerminalLimit,
type TerminalLimitStatus
} from '../../shared/terminal-limit-policy'
import type { HostTerminalPolicy } from './host-terminal-policy'
/** New requests remain unknown on older daemons; no protocol-generation change or replacement. */
export function answerTerminalLimitRequest(
request: { type: string; payload?: unknown },
policy: HostTerminalPolicy,
nonce: string,
isCurrent: () => boolean
): Promise<TerminalLimitStatus> | undefined {
if (request.type !== 'terminalLimitStatus' && request.type !== 'setTerminalLimit') {
return undefined
}
const deadline = Date.now() + 2_000
const work = async (): Promise<TerminalLimitStatus> => {
if (!isCurrent()) {
throw new Error('Terminal host changed. Refresh the setting and try again.')
}
if (request.type === 'setTerminalLimit') {
const update = request.payload
if (
typeof update !== 'object' ||
update === null ||
!('maxTerminals' in update) ||
!isConfiguredTerminalLimit(update.maxTerminals) ||
!('daemonNonce' in update) ||
update.daemonNonce !== nonce
) {
throw new Error('Terminal setting changed. Refresh the setting and try again.')
}
return policy.save(update.maxTerminals, isCurrent, nonce, deadline)
}
return policy.status(nonce, isCurrent, deadline)
}
return work()
}
@@ -0,0 +1,153 @@
import type * as FileSystem from 'node:fs/promises'
import './mock-descendant-sweep'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { DaemonClient } from './client'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import { createMockSubprocess } from './daemon-pty-adapter-test-harness'
import { HostTerminalPolicy } from './host-terminal-policy'
import { SystemPtyLimit } from './system-pty-limit'
import type { TerminalLimitStatus } from '../../shared/terminal-limit-policy'
vi.mock('node:fs/promises', async (original) => {
const fs = await original<typeof FileSystem>()
return { ...fs, writeFile: vi.fn(fs.writeFile), rm: vi.fn(fs.rm) }
})
const fs = await vi.importActual<typeof FileSystem>('node:fs/promises')
let server: DaemonServer | undefined
let client: DaemonClient | undefined
let directory: string | undefined
afterEach(async () => {
client?.disconnect()
await server?.shutdown()
if (directory) {
rmSync(directory, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
it('reads and saves on the attached daemon socket, refusing stale identity and lost endpoint writes', async () => {
directory = mkdtempSync(join(tmpdir(), 'terminal-limit-rpc-'))
const path = join(directory, 'policy', 'terminals.json')
await fs.mkdir(join(directory, 'policy'), { mode: 0o700 })
await fs.writeFile(path, 'invalid JSON')
server = new DaemonServer({
socketPath: getDaemonSocketPath(directory),
tokenPath: join(directory, 'token'),
terminalLimitPolicy: new HostTerminalPolicy(path, {}, new SystemPtyLimit(async () => 511)),
spawnSubprocess: async () => createMockSubprocess()
})
await server.start()
client = new DaemonClient({
socketPath: getDaemonSocketPath(directory),
tokenPath: join(directory, 'token')
})
await client.ensureConnected()
const before = await client.request<TerminalLimitStatus>('terminalLimitStatus', undefined)
expect(before).toMatchObject({
maxTerminals: 256,
systemPtyLimit: 511,
source: 'default',
editable: true,
savedUnreadable: true
})
const repaired = await client.request<TerminalLimitStatus>('setTerminalLimit', {
daemonNonce: before.daemonNonce,
maxTerminals: 256
})
expect(repaired.maxTerminals).toBe(256)
expect(repaired.savedUnreadable).toBeUndefined()
const update = { daemonNonce: before.daemonNonce, maxTerminals: 384 }
await expect(client.request('setTerminalLimit', update)).resolves.toMatchObject({
maxTerminals: 384,
source: 'saved'
})
const saved = readFileSync(path, 'utf8')
await expect(
client.request('setTerminalLimit', { ...update, maxTerminals: 512 })
).rejects.toThrow('capacity')
await expect(
client.request('setTerminalLimit', { ...update, daemonNonce: 'other-daemon', maxTerminals: 20 })
).rejects.toThrow('changed')
vi.spyOn(server['endpoint'], 'hasLostOwnership').mockReturnValue(true)
await expect(client.request('setTerminalLimit', { ...update, maxTerminals: 20 })).rejects.toThrow(
'host changed'
)
expect(readFileSync(path, 'utf8')).toBe(saved)
})
it('recognizes an older daemon without replacement and preserves real transport failures', async () => {
vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue()
const request = vi
.spyOn(DaemonClient.prototype, 'request')
.mockRejectedValue(new Error('Unknown request type: terminalLimitStatus'))
const adapter = new DaemonPtyAdapter({
socketPath: '/tmp/orca-limit-old.sock',
tokenPath: '/tmp/orca-limit-old.token'
})
try {
await expect(adapter.terminalLimit()).resolves.toBeNull()
request.mockRejectedValue(new Error('socket closed'))
await expect(adapter.terminalLimit()).rejects.toThrow('socket closed')
} finally {
adapter.dispose()
}
})
it('fences an actual pending save when the server shuts down before replacement', async () => {
directory = mkdtempSync(join(tmpdir(), 'terminal-limit-shutdown-'))
const path = join(directory, 'policy', 'terminals.json')
server = new DaemonServer({
socketPath: getDaemonSocketPath(directory),
tokenPath: join(directory, 'token'),
terminalLimitPolicy: new HostTerminalPolicy(path, {}, new SystemPtyLimit(async () => 511)),
spawnSubprocess: async () => createMockSubprocess()
})
await server.start()
client = new DaemonClient({
socketPath: getDaemonSocketPath(directory),
tokenPath: join(directory, 'token')
})
await client.ensureConnected()
const before = await client.request<TerminalLimitStatus>('terminalLimitStatus', undefined)
let release!: () => void
let started!: () => void
const held = new Promise<void>((done) => {
release = done
})
const staging = new Promise<void>((done) => {
started = done
})
vi.mocked(writeFile).mockImplementationOnce(async (...args) => {
started()
await held
await fs.writeFile(...args)
})
let cleaned!: () => void
const cleanup = new Promise<void>((done) => {
cleaned = done
})
vi.mocked(rm).mockImplementationOnce(async (...args) => {
await fs.rm(...args)
cleaned()
})
const save = client.request('setTerminalLimit', {
daemonNonce: before.daemonNonce,
maxTerminals: 10
})
const failed = expect(save).rejects.toThrow()
await staging
await server.shutdown()
release()
await failed
await cleanup
expect(await fs.readdir(join(directory, 'policy'))).toEqual([])
expect(() => readFileSync(path)).toThrow()
})
+3 -3
View File
@@ -106,12 +106,12 @@ export class HostTerminalLedger {
}
/** `own` excludes the terminal being reserved; on false its previous share is restored. */
tryReserve(own: HostTerminalShare): boolean {
if (own.terminals + 1 > this.options.maxTerminals) {
tryReserve(own: HostTerminalShare, maximum = this.options.maxTerminals): boolean {
if (own.terminals + 1 > maximum) {
return false
}
this.write({ ...own, terminals: own.terminals + 1 })
if (own.terminals + 1 + this.readOthers().terminals <= this.options.maxTerminals) {
if (own.terminals + 1 + this.readOthers().terminals <= maximum) {
return true
}
this.write(own)
@@ -0,0 +1,511 @@
import type * as FileSystem from 'node:fs/promises'
import { mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { HostTerminalPolicy, hostTerminalPolicyPath } from './host-terminal-policy'
import { SystemPtyLimit, parseLinuxPtyLimit } from './system-pty-limit'
import * as deadlines from './daemon-request-deadline'
import { HostTerminalLedger } from './host-terminal-ledger'
import { TerminalSpawnGate } from './terminal-spawn-gate'
import { isConfiguredTerminalLimit } from '../../shared/terminal-limit-policy'
import {
DEFAULT_SPAWN_ADMISSION_LIMITS,
type SpawnRequestFacts
} from '../../shared/host-spawn-admission'
vi.mock('node:fs/promises', async (original) => {
const fs = await original<typeof FileSystem>()
return { ...fs, writeFile: vi.fn(fs.writeFile), rename: vi.fn(fs.rename), rm: vi.fn(fs.rm) }
})
const fs = await vi.importActual<typeof FileSystem>('node:fs/promises')
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
const facts: SpawnRequestFacts = {
clientId: 'user',
declaredIntent: null,
hasAgentSessionClaim: false,
hasHistorySeed: false,
deadlineAt: Number.POSITIVE_INFINITY
}
const system = () => new SystemPtyLimit(async () => 511)
const policies: HostTerminalPolicy[] = []
function createPolicy(
...args: ConstructorParameters<typeof HostTerminalPolicy>
): HostTerminalPolicy {
const value = new HostTerminalPolicy(...args)
policies.push(value)
return value
}
const directories: string[] = []
afterEach(async () => {
policies.splice(0).forEach((value) => value.dispose())
vi.useRealTimers()
await Promise.all(
directories.splice(0).map((path) => fs.rm(path, { recursive: true, force: true }))
)
vi.resetAllMocks()
})
async function home(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-terminal-policy-'))
directories.push(path)
return path
}
describe('host terminal policy', () => {
it('shares live raises and decreases across daemons and retains the policy after reconstruction', async () => {
const root = await home()
const path = hostTerminalPolicyPath(root, 'machine-a')
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const first = createPolicy(path, {}, system())
const second = createPolicy(path, {}, system())
const gate = (policy: HostTerminalPolicy, pid: number) =>
new TerminalSpawnGate({
log: vi.fn(),
resolveMaximum: () => policy.admissionMaximum(),
hostTerminals: new HostTerminalLedger({
dir: join(root, 'ledger'),
pid,
maxTerminals: 256,
isProcessAlive: () => true,
log: vi.fn()
})
})
const log = vi.fn()
await Promise.all([first.start(log), second.start()])
expect(log).not.toHaveBeenCalled()
const desktop = gate(first, 100)
const server = gate(second, 200)
await first.save(1, () => true)
const running = await desktop.admit(facts)
running.commit('existing-shell', true)
await vi.advanceTimersByTimeAsync(3_000)
await vi.waitFor(() => expect(second.admissionMaximum()).toBe(first.admissionMaximum()))
expect(() => server.admit(facts)).toThrow()
await first.save(2, () => true)
expect(first.admissionMaximum()).toBe(2)
expect(second.admissionMaximum()).toBe(1)
await vi.advanceTimersByTimeAsync(3_000)
await vi.waitFor(() => expect(second.admissionMaximum()).toBe(2))
const setup = await server.admit(facts)
setup.commit('setup-script', true)
await first.save(1, () => true)
await vi.advanceTimersByTimeAsync(3_000)
await vi.waitFor(() => expect(second.admissionMaximum()).toBe(first.admissionMaximum()))
expect(() => server.admit(facts)).toThrow()
expect(desktop.snapshot().terminals).toBe(1)
expect(server.snapshot().terminals).toBe(1)
const restarted = createPolicy(path, {}, system())
await restarted.start()
expect(restarted.admissionMaximum()).toBe(1)
desktop.onSessionExit('existing-shell')
expect(() => server.admit(facts)).toThrow()
server.onSessionExit('setup-script')
const next = await server.admit(facts)
next.abandon()
desktop.stopHostTerminals()
server.stopHostTerminals()
})
it('caches the capped startup default, validates host capacity and preserves saved/environment priority', async () => {
const root = await home()
const path = hostTerminalPolicyPath(root, 'machine-a')
expect(path).not.toBe(hostTerminalPolicyPath(root, 'machine-b'))
expect(hostTerminalPolicyPath(root, null)).toBeNull()
const policy = createPolicy(path, {}, system())
await expect(policy.save(512, () => true)).rejects.toThrow('capacity')
await policy.save(384, () => true)
expect(await policy.status('nonce')).toMatchObject({
maxTerminals: 384,
source: 'saved',
systemPtyLimit: 511
})
const read = vi.fn()
const override = createPolicy(path, { ORCA_DAEMON_MAX_HOST_TERMINALS: '600' }, system(), read)
await override.start()
expect(read).not.toHaveBeenCalled()
expect(await override.status('nonce')).toMatchObject({
maxTerminals: 600,
source: 'environment',
editable: false
})
await expect(override.save(20, () => true)).rejects.toThrow('unavailable')
const unidentified = createPolicy(null, {}, system())
await unidentified.start()
expect(await unidentified.status('nonce')).toMatchObject({ maxTerminals: 256, editable: false })
await expect(unidentified.save(10, () => true)).rejects.toThrow()
const reduced = createPolicy(path, {}, new SystemPtyLimit(async () => 128))
await reduced.start()
expect(await reduced.status('nonce')).toMatchObject({ maxTerminals: 384, systemPtyLimit: 128 })
for (const [capacity, expected] of [
[null, 256],
[511, 256],
[4096, 2048],
[16384, 4096]
] as const) {
const probe = vi.fn<() => Promise<number | null>>().mockResolvedValue(capacity)
const defaults = createPolicy(null, {}, new SystemPtyLimit(probe))
await defaults.start()
expect(defaults.admissionMaximum()).toBe(expected)
expect(defaults.admissionMaximum()).toBe(expected)
expect(probe).toHaveBeenCalledOnce()
probe.mockResolvedValue(128)
expect(await defaults.status('nonce')).toMatchObject({
maxTerminals: expected,
source: 'default',
systemPtyLimit: 128
})
expect(defaults.admissionMaximum()).toBe(expected)
}
})
it('publishes slow startup, raises and decreases on settlement without background observers or transient health', async () => {
vi.useFakeTimers()
let saved = 384
let latency = 300
const read = vi.fn(() => {
const maximum = saved
// ast-grep-ignore: test-no-real-time-wait -- Fake timers model read latency across startup and refresh ticks.
return new Promise<number>((resolve) => setTimeout(() => resolve(maximum), latency))
})
const value = createPolicy(join(await home(), 'policy.json'), {}, system(), read)
const log = vi.fn()
const observed = vi.spyOn(deadlines, 'awaitDaemonValueWithinCallerDeadline')
const startup = value.start(log)
expect(value.start()).toBe(startup)
await vi.advanceTimersByTimeAsync(251)
await startup
expect(await value.status('nonce')).toMatchObject({ maxTerminals: 256, source: 'default' })
expect((await value.status('nonce')).savedUnreadable).toBeUndefined()
const observers = observed.mock.calls.length
await vi.advanceTimersByTimeAsync(49)
expect(value.admissionMaximum()).toBe(384)
for (const maximum of [400, 300]) {
saved = maximum
await vi.advanceTimersToNextTimerAsync()
await vi.advanceTimersByTimeAsync(300)
expect(value.admissionMaximum()).toBe(maximum)
}
latency = 3_100
saved = 350
await vi.advanceTimersToNextTimerAsync()
await vi.advanceTimersByTimeAsync(3_000)
expect(read).toHaveBeenCalledTimes(4)
expect(value.admissionMaximum()).toBe(300)
await vi.advanceTimersByTimeAsync(100)
expect(value.admissionMaximum()).toBe(350)
expect(observed).toHaveBeenCalledTimes(observers)
saved = 2
await vi.advanceTimersToNextTimerAsync()
value.dispose()
await vi.advanceTimersByTimeAsync(30_000)
expect(read).toHaveBeenCalledTimes(5)
expect(value.admissionMaximum()).toBe(350)
expect(log).not.toHaveBeenCalled()
})
it('bounds shared startup and retains one raw read/observer across ticks, admissions and independent saves', async () => {
const path = join(await home(), 'policy.json')
vi.useFakeTimers()
const old = deferred<number | null>()
const read = vi
.fn()
.mockImplementationOnce(() => old.promise)
.mockResolvedValue(384)
const value = createPolicy(path, {}, system(), read)
const observed = vi.spyOn(deadlines, 'awaitDaemonValueWithinCallerDeadline')
const log = vi.fn()
const startup = value.start(log)
expect(value.start()).toBe(startup)
await vi.advanceTimersByTimeAsync(251)
await startup
expect(await value.status('nonce')).toMatchObject({ maxTerminals: 256 })
expect((await value.status('nonce')).savedUnreadable).toBeUndefined()
expect(await value.save(384, () => true)).toMatchObject({ maxTerminals: 384 })
expect(await value.save(400, () => true)).toMatchObject({ maxTerminals: 400 })
expect(await value.save(384, () => true)).toMatchObject({ maxTerminals: 384 })
const observers = observed.mock.calls.length
const maximum = vi.fn(() => value.admissionMaximum())
const gate = new TerminalSpawnGate({
log: vi.fn(),
mode: 'enforce',
limits: { ...DEFAULT_SPAWN_ADMISSION_LIMITS, automaticConcurrency: 1 },
resolveMaximum: maximum
})
for (const n of [30, 300]) {
for (let i = 0; i < n; i++) {
const lease = gate.admit(facts)
if (lease instanceof Promise) {
throw new Error('Admission must be synchronous')
}
lease.commit(`${n}-${i}`, true)
}
await vi.advanceTimersByTimeAsync(30_000)
expect(read).toHaveBeenCalledOnce()
expect(observed).toHaveBeenCalledTimes(observers)
}
expect(gate.snapshot().terminals).toBe(330)
const automatic = { ...facts, declaredIntent: 'automatic-restore' as const }
const first = await gate.admit(automatic)
const waiting = gate.admit(automatic)
expect(waiting).toBeInstanceOf(Promise)
expect(gate.snapshot().waiting).toBe(1)
const beforeWake = maximum.mock.calls.length
first.commit('first', true)
const next = await waiting
next.commit('next', true)
gate.onSessionExit('first')
gate.onSessionExit('next')
expect(maximum).toHaveBeenCalledTimes(beforeWake + 1)
expect(maximum.mock.results.at(-1)?.value).toBe(384)
expect(read).toHaveBeenCalledOnce()
expect(gate.snapshot()).toMatchObject({
terminals: 330,
waiting: 0,
inFlight: { automatic: 0 }
})
old.resolve(2)
await vi.advanceTimersByTimeAsync(0)
expect(value.admissionMaximum()).toBe(384)
expect(read).toHaveBeenCalledTimes(2)
expect(observed).toHaveBeenCalledTimes(observers)
expect(log).not.toHaveBeenCalled()
value.dispose()
await vi.advanceTimersByTimeAsync(30_000)
expect(read).toHaveBeenCalledTimes(2)
const systemProbe = deferred<number | null>()
const cold = createPolicy(null, {}, new SystemPtyLimit(() => systemProbe.promise))
const initializing = cold.start()
expect(cold.start()).toBe(initializing)
await vi.advanceTimersByTimeAsync(1_101)
await initializing
expect(cold.admissionMaximum()).toBe(256)
systemProbe.resolve(16384)
await vi.advanceTimersByTimeAsync(0)
expect(cold.admissionMaximum()).toBe(256)
})
it('retains last good through failures, fences stale reads against save and disposal, and recovers', async () => {
const path = join(await home(), 'policy.json')
vi.useFakeTimers()
const held = deferred<number | null>()
const read = vi.fn<() => Promise<number | null>>().mockResolvedValue(384)
const value = createPolicy(path, {}, system(), read)
const log = vi.fn()
await value.start(log)
read.mockRejectedValue(new SyntaxError('Invalid JSON'))
await vi.advanceTimersByTimeAsync(6_000)
read.mockRejectedValue(Object.assign(new Error('denied'), { code: 'EACCES' }))
await vi.advanceTimersByTimeAsync(3_000)
read.mockResolvedValue(null)
await vi.advanceTimersByTimeAsync(3_000)
read.mockReturnValue(held.promise)
await vi.advanceTimersByTimeAsync(3_251)
expect(value.admissionMaximum()).toBe(384)
expect(await value.status('nonce')).toMatchObject({
maxTerminals: 384,
source: 'saved',
savedUnreadable: true,
editable: true
})
expect(log).toHaveBeenCalledTimes(1)
setTimeout(() => held.resolve(400), 300)
read.mockResolvedValue(400)
await vi.advanceTimersByTimeAsync(299)
expect(value.admissionMaximum()).toBe(384)
expect(log).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
const recovered = await value.status('nonce')
expect(recovered.maxTerminals).toBe(400)
expect(recovered.savedUnreadable).toBeUndefined()
expect(log.mock.calls.map(([event]) => event)).toEqual([
'terminal-policy-read-failed',
'terminal-policy-read-recovered'
])
const rejected = deferred<number | null>()
read.mockReturnValueOnce(rejected.promise)
await vi.advanceTimersToNextTimerAsync()
await expect(value.save(512, () => true)).rejects.toThrow('capacity')
rejected.reject(new Error('obsolete denied read'))
await vi.advanceTimersByTimeAsync(0)
expect(value.admissionMaximum()).toBe(400)
expect((await value.status('nonce')).savedUnreadable).toBeUndefined()
expect(log).toHaveBeenCalledTimes(2)
read.mockImplementationOnce(() => {
throw new Error('actual synchronous loader failure')
})
await vi.advanceTimersToNextTimerAsync()
expect((await value.status('nonce')).savedUnreadable).toBe(true)
expect(log).toHaveBeenCalledTimes(3)
const stale = deferred<number | null>()
const between = deferred<number | null>()
read
.mockReturnValueOnce(stale.promise)
.mockReturnValueOnce(between.promise)
.mockResolvedValue(384)
await vi.advanceTimersToNextTimerAsync()
const committing = deferred<void>()
const release = deferred<void>()
vi.mocked(rename).mockImplementationOnce(async (...args) => {
committing.resolve()
await release.promise
await fs.rename(...args)
})
const save = value.save(384, () => true)
await committing.promise
stale.resolve(2)
await vi.advanceTimersByTimeAsync(0)
expect(await value.status('nonce')).toMatchObject({ maxTerminals: 400, savedUnreadable: true })
expect(log).toHaveBeenCalledTimes(3)
release.resolve()
expect(await save).toMatchObject({ maxTerminals: 384 })
between.reject(new Error('obsolete pre-commit denial'))
await vi.advanceTimersByTimeAsync(0)
expect(value.admissionMaximum()).toBe(384)
expect((await value.status('nonce')).savedUnreadable).toBeUndefined()
expect(log).toHaveBeenCalledTimes(4)
const late = deferred<number | null>()
read.mockReturnValue(late.promise)
await vi.advanceTimersToNextTimerAsync()
value.dispose()
const count = read.mock.calls.length
late.reject(new Error('disposed read failure'))
await vi.advanceTimersByTimeAsync(30_000)
expect(read).toHaveBeenCalledTimes(count)
expect(value.admissionMaximum()).toBe(384)
expect(log).toHaveBeenCalledTimes(4)
})
it('expires staging without a late rename or orphan, and permits retry while cleanup stalls', async () => {
const path = join(await home(), 'policy.json')
const started = deferred<void>()
const release = deferred<void>()
const staged = deferred<void>()
const cleaned = deferred<void>()
const cleanup = deferred<void>()
let expiredTemporary: Parameters<typeof writeFile>[0]
vi.mocked(writeFile).mockImplementationOnce(async (...args) => {
expiredTemporary = args[0]
started.resolve()
await release.promise
await fs.writeFile(...args)
staged.resolve()
})
vi.mocked(rm).mockImplementation(async (...args) => {
if (args[0] !== expiredTemporary) {
await cleanup.promise
}
await fs.rm(...args)
if (args[0] === expiredTemporary) {
cleaned.resolve()
}
})
vi.useFakeTimers()
const policy = createPolicy(path, {}, system())
const save = policy.save(10, () => true)
const failed = expect(save).rejects.toThrow('confirm')
await started.promise
await vi.advanceTimersByTimeAsync(2_001)
await failed
expect(rename).not.toHaveBeenCalled()
expect(rm).not.toHaveBeenCalled()
vi.useRealTimers()
expect(await policy.save(20, () => true)).toMatchObject({ maxTerminals: 20 })
expect(await policy.save(30, () => true)).toMatchObject({ maxTerminals: 30 })
release.resolve()
await staged.promise
await cleaned.promise
cleanup.resolve()
expect(await policy.admissionMaximum()).toBe(30)
expect(rename).toHaveBeenCalledTimes(2)
expect(await readdir(join(path, '..'))).toEqual(['policy.json'])
})
it.each(['completed', 'retryable failure'])(
'keeps an expired issued rename unconfirmed: %s',
async (outcome) => {
const path = join(await home(), 'policy.json')
const started = deferred<void>()
const release = deferred<void>()
const cleaned = deferred<void>()
let issuedTemporary: Parameters<typeof rename>[0]
vi.mocked(rename).mockImplementationOnce(async (...args) => {
issuedTemporary = args[0]
started.resolve()
await release.promise
if (outcome === 'retryable failure') {
throw Object.assign(new Error('busy'), { code: 'EPERM' })
}
await fs.rename(...args)
})
vi.mocked(rm).mockImplementation(async (...args) => {
await fs.rm(...args)
if (args[0] === issuedTemporary) {
cleaned.resolve()
}
})
const reconciling = deferred<void>()
const read = vi.fn(async (file: string) => {
const saved: unknown = JSON.parse(await fs.readFile(file, 'utf8'))
if (
!saved ||
typeof saved !== 'object' ||
!('maxTerminals' in saved) ||
!isConfiguredTerminalLimit(saved.maxTerminals)
) {
throw new Error('Invalid fixture')
}
reconciling.resolve()
// ast-grep-ignore: test-no-real-time-wait -- Fake timers verify delayed actual-file reconciliation after an expired rename.
await new Promise((resolve) => setTimeout(resolve, 300))
return saved.maxTerminals
})
const policy = createPolicy(path, {}, system(), read)
vi.useFakeTimers()
const save = policy.save(10, () => true)
const failed = expect(save).rejects.toThrow('confirm')
await started.promise
await vi.advanceTimersByTimeAsync(2_001)
await failed
expect(await policy.save(20, () => true)).toMatchObject({ maxTerminals: 20 })
const platform = process.platform
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
release.resolve()
await vi.advanceTimersByTimeAsync(0)
await cleaned.promise
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
expect(policy.admissionMaximum()).toBe(20)
if (outcome === 'completed') {
await reconciling.promise
await vi.advanceTimersByTimeAsync(299)
expect(policy.admissionMaximum()).toBe(20)
await vi.advanceTimersByTimeAsync(1)
expect(policy.admissionMaximum()).toBe(10)
expect(read).toHaveBeenCalledOnce()
} else {
expect(read).not.toHaveBeenCalled()
}
expect(rename).toHaveBeenCalledTimes(2)
}
)
it('reports Linux configured global and devpts capacity without subtracting namespace reserve', () => {
expect(parseLinuxPtyLimit('4096\n', 'devpts /dev/pts devpts rw,max=128 0 0')).toBe(128)
expect(parseLinuxPtyLimit('4096', 'devpts /dev/pts devpts rw 0 0')).toBe(4096)
expect(parseLinuxPtyLimit('garbage', '')).toBeNull()
})
})
+289
View File
@@ -0,0 +1,289 @@
import { createHash, randomUUID } from 'node:crypto'
import { lstat, mkdir, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
import { renameFileWithWindowsRetryAsync } from '../../shared/windows-retry-file-operations'
import {
isConfiguredTerminalLimit,
MAX_CONFIGURED_TERMINALS,
terminalLimitInputMaximum,
type TerminalLimitStatus
} from '../../shared/terminal-limit-policy'
import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity'
import { isSafeOwnedDirectory } from '../../shared/owned-private-directory'
import { awaitDaemonValueWithinCallerDeadline } from './daemon-request-deadline'
import { DEFAULT_MAX_HOST_TERMINALS, resolveMaxHostTerminals } from './host-terminal-ledger'
import { SystemPtyLimit } from './system-pty-limit'
export function hostTerminalPolicyPath(home: string, machine: string | null): string | null {
if (!machine) {
return null
}
const key = createHash('sha256')
.update(`${process.platform}:${machine}`)
.digest('hex')
.slice(0, 16)
return join(home, '.orca', 'host-settings', key, 'terminals.json')
}
async function readSavedMaximum(path: string): Promise<number | null> {
try {
const { buffer, stats } = await readNodeFileWithinLimit(path, 1024, { regularFileOnly: true })
if (process.getuid && stats.uid !== process.getuid()) {
throw new Error('Policy owner changed')
}
const saved: unknown = JSON.parse(buffer.toString('utf8'))
if (
typeof saved !== 'object' ||
saved === null ||
!('schemaVersion' in saved) ||
saved.schemaVersion !== 1 ||
!('maxTerminals' in saved) ||
!isConfiguredTerminalLimit(saved.maxTerminals)
) {
throw new Error('Invalid terminal policy')
}
return saved.maxTerminals
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return null
}
throw error
}
}
type PolicyOperation = {
isCurrent: () => boolean
wait: <T>(load: () => Promise<T>) => Promise<T>
}
async function withPolicyOperation<T>(
isHostCurrent: () => boolean,
deadline: number,
work: (operation: PolicyOperation) => Promise<T>
): Promise<T> {
let active = true
const isCurrent = (): boolean => active && Date.now() < deadline && isHostCurrent()
const check = (): void => {
if (!isCurrent()) {
throw new Error('Couldn’t confirm the terminal setting. Refresh and try again.')
}
}
const operation: PolicyOperation = {
isCurrent,
wait: async (load) => {
check()
const result = await awaitDaemonValueWithinCallerDeadline(load(), deadline)
check()
if (!result.completed) {
throw new Error('Couldn’t confirm the terminal setting. Refresh and try again.')
}
return result.value
}
}
try {
const result = await work(operation)
check()
return result
} finally {
active = false
}
}
export class HostTerminalPolicy {
private defaultMaximum = DEFAULT_MAX_HOST_TERMINALS
private savedMaximum: number | null = null
private savedUnreadable = false
private revision = 0
private disposed = false
private reading = false
private initialization?: Promise<void>
private timer?: ReturnType<typeof setInterval>
private log: (event: string, details: Record<string, unknown>) => void = () => {}
private readonly environment: number | null
constructor(
readonly path: string | null = hostTerminalPolicyPath(
homedir(),
profileStateAccessMachineIdentity()
),
env: NodeJS.ProcessEnv = process.env,
private readonly system = new SystemPtyLimit(),
private readonly readSaved: (path: string) => Promise<number | null> = readSavedMaximum
) {
const raw = env.ORCA_DAEMON_MAX_HOST_TERMINALS?.trim()
this.environment =
raw && /^\d+$/.test(raw) && isConfiguredTerminalLimit(Number(raw))
? resolveMaxHostTerminals(env)
: null
}
start(log = this.log): Promise<void> {
this.log = log
return (this.initialization ??= this.system.read().then(async (systemPtyLimit) => {
if (this.disposed) {
return
}
this.defaultMaximum =
systemPtyLimit === null
? DEFAULT_MAX_HOST_TERMINALS
: Math.min(Math.ceil(systemPtyLimit / 2), MAX_CONFIGURED_TERMINALS)
await awaitDaemonValueWithinCallerDeadline(this.refresh(), Date.now() + 250)
if (!this.disposed && this.path && this.environment === null) {
this.timer = setInterval(() => void this.refresh(), 3_000)
this.timer.unref?.()
}
}))
}
dispose(): void {
this.disposed = true
this.revision += 1
clearInterval(this.timer)
}
private async refresh(): Promise<void> {
if (this.disposed || this.reading || !this.path || this.environment !== null) {
return
}
const path = this.path
const revision = this.revision
this.reading = true
try {
const maximum = await this.readSaved(path)
if (this.disposed || revision !== this.revision) {
return
}
if (maximum === null && this.savedMaximum !== null) {
throw new Error('Saved terminal limit unavailable')
}
this.savedMaximum = maximum
this.setReadHealth(false)
} catch {
if (!this.disposed && revision === this.revision) {
this.setReadHealth(true)
}
} finally {
this.reading = false
if (!this.disposed && revision !== this.revision) {
void this.refresh()
}
}
}
private setReadHealth(failed: boolean): void {
if (failed === this.savedUnreadable) {
return
}
this.savedUnreadable = failed
try {
this.log(failed ? 'terminal-policy-read-failed' : 'terminal-policy-read-recovered', {
maxTerminals: this.admissionMaximum()
})
} catch {
// Diagnostics cannot change the applied limit.
}
}
admissionMaximum(): number {
return this.environment ?? this.savedMaximum ?? this.defaultMaximum
}
private observedStatus(systemPtyLimit: number | null, daemonNonce: string): TerminalLimitStatus {
return {
maxTerminals: this.admissionMaximum(),
source:
this.environment !== null
? 'environment'
: this.savedMaximum !== null
? 'saved'
: 'default',
...(this.savedUnreadable ? { savedUnreadable: true } : {}),
systemPtyLimit,
editable: this.environment === null && this.path !== null,
daemonNonce
}
}
status(
daemonNonce: string,
isCurrent: () => boolean = () => true,
deadline = Date.now() + 2_000
): Promise<TerminalLimitStatus> {
return withPolicyOperation(isCurrent, deadline, async (operation) => {
const systemPtyLimit = await operation.wait(() => this.system.read())
return this.observedStatus(systemPtyLimit, daemonNonce)
})
}
save(
maximum: number,
isCurrent: () => boolean,
daemonNonce = '',
deadline = Date.now() + 2_000
): Promise<TerminalLimitStatus> {
this.revision += 1
const current = (): boolean => !this.disposed && isCurrent()
return withPolicyOperation(current, deadline, async (operation) => {
const path = this.path
if (!path || this.environment !== null) {
throw new Error('Terminal setting unavailable')
}
const systemPtyLimit = await operation.wait(() => this.system.read())
if (
!isConfiguredTerminalLimit(maximum) ||
maximum > terminalLimitInputMaximum(systemPtyLimit)
) {
throw new Error('Terminal limit exceeds host capacity')
}
const directory = dirname(path)
await operation.wait(() => mkdir(directory, { recursive: true, mode: 0o700 }))
if (!isSafeOwnedDirectory(await operation.wait(() => lstat(directory)))) {
throw new Error('Terminal policy directory is not private')
}
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
let staging: Promise<unknown> = Promise.resolve()
try {
await operation.wait(() => {
staging = writeFile(
temporary,
JSON.stringify({ schemaVersion: 1, maxTerminals: maximum }),
{
flag: 'wx',
mode: 0o600
}
)
return staging
})
const replaced = await operation.wait(() => {
const rename = renameFileWithWindowsRetryAsync(temporary, path, operation.isCurrent)
const committed = rename.then((replaced) => {
if (replaced && !this.disposed) {
this.revision += 1
if (operation.isCurrent()) {
this.savedMaximum = maximum
this.setReadHealth(false)
} else {
void this.refresh()
}
}
return replaced
})
staging = committed
return committed
})
if (!replaced) {
throw new Error('Terminal host changed')
}
return this.observedStatus(systemPtyLimit, daemonNonce)
} finally {
// A late staging write must settle before removal can consume its temporary file.
void staging
.catch(() => undefined)
.then(() => rm(temporary, { force: true }))
.catch(() => undefined)
}
})
}
}
+64
View File
@@ -0,0 +1,64 @@
import { runProcess } from '@orca/process-host'
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
import { InFlightPromiseDedupe } from '../../shared/in-flight-promise-dedupe'
import { awaitDaemonValueWithinCallerDeadline } from './daemon-request-deadline'
function positiveInteger(raw: string): number | null {
const text = raw.trim()
const value = Number(text)
return /^\d+$/.test(text) && Number.isSafeInteger(value) && value > 0 ? value : null
}
export function parseLinuxPtyLimit(globalRaw: string, mounts: string): number | null {
const global = positiveInteger(globalRaw)
if (global === null) {
return null
}
const instance = mounts.split('\n').find((line) => {
const fields = line.split(/\s+/)
return fields[1] === '/dev/pts' && fields[2] === 'devpts'
})
const maximum = instance
?.split(/\s+/)[3]
?.split(',')
.find((part) => part.startsWith('max='))
const local = maximum ? positiveInteger(maximum.slice(4)) : null
// The reserve belongs to mount namespaces, not UIDs; this reports capacity, never free slots.
return maximum && local === null ? null : Math.min(global, local ?? global)
}
async function probeSystemPtyLimit(): Promise<number | null> {
if (process.platform === 'darwin') {
const result = await runProcess({
program: '/usr/sbin/sysctl',
args: ['-n', 'kern.tty.ptmx_max'],
timeoutMs: 1_000,
maxOutputBytes: 1024
})
return result.code === 0 && !result.timedOut && !result.outputTruncated
? positiveInteger(result.stdout)
: null
}
if (process.platform === 'linux') {
const [global, mounts] = await Promise.all([
readNodeFileWithinLimit('/proc/sys/kernel/pty/max', 1024),
readNodeFileWithinLimit('/proc/self/mounts', 1024 * 1024)
])
return parseLinuxPtyLimit(global.buffer.toString('utf8'), mounts.buffer.toString('utf8'))
}
return null
}
/** A stuck filesystem probe stays coalesced; caller deadlines never create more outstanding I/O. */
export class SystemPtyLimit {
private readonly pending = new InFlightPromiseDedupe<number | null>(0, 1)
constructor(private readonly probe: () => Promise<number | null> = probeSystemPtyLimit) {}
async read(): Promise<number | null> {
const result = await awaitDaemonValueWithinCallerDeadline(
this.pending.run('system', () => this.probe().catch(() => null)),
Date.now() + 1_100
)
return result.completed ? result.value : null
}
}
@@ -0,0 +1,21 @@
export type TerminalSpawnAdmissionMode = 'observe' | 'enforce'
export const TERMINAL_SPAWN_ADMISSION_ARG = '--spawn-admission'
// Rollback switch: `observe` makes newly launched daemons count decisions without pacing.
export const TERMINAL_SPAWN_ADMISSION_ENV_VAR = 'ORCA_TERMINAL_SPAWN_ADMISSION'
/** The mode a launcher passes its daemon: enforce, unless the env var asks for `observe`. */
export function resolveTerminalSpawnAdmissionMode(
env: NodeJS.ProcessEnv = process.env
): TerminalSpawnAdmissionMode {
return env[TERMINAL_SPAWN_ADMISSION_ENV_VAR]?.trim().toLowerCase() === 'observe'
? 'observe'
: 'enforce'
}
/** Reads the launcher's `--spawn-admission <mode>`; anything else observes. */
export function readTerminalSpawnAdmissionArg(argv: readonly string[]): TerminalSpawnAdmissionMode {
const index = argv.indexOf(TERMINAL_SPAWN_ADMISSION_ARG)
return index !== -1 && argv[index + 1] === 'enforce' ? 'enforce' : 'observe'
}
+22 -29
View File
@@ -24,30 +24,19 @@ import { admitRecordingLegacyRestore } from './legacy-restore-admission'
import { TerminalSpawnWaiters } from './terminal-spawn-waiters'
import { hostTerminalCapRefusal, TerminalSpawnHostShare } from './terminal-spawn-host-share'
export type TerminalSpawnAdmissionMode = 'observe' | 'enforce'
import type { TerminalSpawnAdmissionMode } from './terminal-spawn-admission-mode'
export {
TERMINAL_SPAWN_ADMISSION_ARG,
TERMINAL_SPAWN_ADMISSION_ENV_VAR,
resolveTerminalSpawnAdmissionMode,
readTerminalSpawnAdmissionArg
} from './terminal-spawn-admission-mode'
export type { TerminalSpawnAdmissionMode } from './terminal-spawn-admission-mode'
const RECENT_AUTOMATIC_WORK_MS = 10 * 60_000
// Poll because another daemon releasing a slot cannot wake local waiters.
const OTHER_DAEMON_POLL_MS = 500
export const TERMINAL_SPAWN_ADMISSION_ARG = '--spawn-admission'
// Observe bypasses pacing while retaining decisions for diagnostics.
export const TERMINAL_SPAWN_ADMISSION_ENV_VAR = 'ORCA_TERMINAL_SPAWN_ADMISSION'
export function resolveTerminalSpawnAdmissionMode(
env: NodeJS.ProcessEnv = process.env
): TerminalSpawnAdmissionMode {
return env[TERMINAL_SPAWN_ADMISSION_ENV_VAR]?.trim().toLowerCase() === 'observe'
? 'observe'
: 'enforce'
}
export function readTerminalSpawnAdmissionArg(argv: readonly string[]): TerminalSpawnAdmissionMode {
const index = argv.indexOf(TERMINAL_SPAWN_ADMISSION_ARG)
return index !== -1 && argv[index + 1] === 'enforce' ? 'enforce' : 'observe'
}
export type TerminalSpawnLease = {
readonly charge: SpawnCharge
/** The fork published a session; only a live automatic one holds a live-cap slot. */
@@ -79,6 +68,7 @@ type TerminalSpawnGateOptions = {
/** Runs synchronously before the first automatic fork is admitted. */
armAutomaticWork?: () => void
/** The execution host's terminal cap; defaults to capping this daemon alone. */
resolveMaximum?: () => number
hostTerminals?: HostTerminalLedger
/** Retain refused restores for clients that already discarded their own resume records. */
legacyRestores?: LegacyRestoreRecords
@@ -119,7 +109,7 @@ export class TerminalSpawnGate {
)
}
/** Keep unpaced admission synchronous to preserve ordering against the host's shutdown fence. */
/** Only pacing adds an async boundary; callers recheck shutdown after await. */
admit(
facts: SpawnRequestFacts,
cancel?: { signal: AbortSignal; error: () => Error },
@@ -127,7 +117,8 @@ export class TerminalSpawnGate {
): TerminalSpawnLease | Promise<TerminalSpawnLease> {
return admitRecordingLegacyRestore(
() => {
const attempt = this.attempt(facts, cancel, false)
const maximum = this.options.resolveMaximum?.() ?? this.host.maxTerminals
const attempt = this.attempt(facts, cancel, false, maximum)
return attempt.kind === 'lease'
? attempt.lease
: this.admitAfterWait(facts, cancel, attempt)
@@ -146,7 +137,8 @@ export class TerminalSpawnGate {
let attempt: ReturnType<TerminalSpawnGate['attempt']> = first
while (attempt.kind === 'wait') {
await this.waiters.waitForChange(attempt.untilMs, cancel?.signal)
attempt = this.attempt(facts, cancel, true)
const maximum = this.options.resolveMaximum?.() ?? this.host.maxTerminals
attempt = this.attempt(facts, cancel, true, maximum)
}
return attempt.lease
}
@@ -154,7 +146,8 @@ export class TerminalSpawnGate {
private attempt(
facts: SpawnRequestFacts,
cancel: { signal: AbortSignal; error: () => Error } | undefined,
alreadyWaited: boolean
alreadyWaited: boolean,
maximum: number
): { kind: 'lease'; lease: TerminalSpawnLease } | { kind: 'wait'; untilMs: number } {
if (cancel?.signal.aborted) {
throw cancel.error()
@@ -180,8 +173,8 @@ export class TerminalSpawnGate {
})
if (this.mode === 'observe' || decision.kind === 'admit') {
// Reserve first so refused forks cannot be logged as admitted.
if (!this.host.tryReserve(this.state)) {
this.refuseAtHostCap(facts, charge)
if (!this.host.tryReserve(this.state, maximum)) {
this.refuseAtHostCap(facts, charge, maximum)
}
this.record(decision, charge, facts, !alreadyWaited)
return { kind: 'lease', lease: this.lease(facts, charge, now) }
@@ -189,9 +182,9 @@ export class TerminalSpawnGate {
// Refuse at the host cap before spending time in the pacing queue.
if (
decision.kind === 'wait' &&
this.host.terminals(this.state) + 1 + others.terminals > this.host.maxTerminals
this.host.terminals(this.state) + 1 + others.terminals > maximum
) {
this.refuseAtHostCap(facts, charge)
this.refuseAtHostCap(facts, charge, maximum)
}
this.record(decision, charge, facts, !alreadyWaited)
if (decision.kind === 'defer') {
@@ -255,14 +248,14 @@ export class TerminalSpawnGate {
this.host.stop()
}
private refuseAtHostCap(facts: SpawnRequestFacts, charge: SpawnCharge): never {
private refuseAtHostCap(facts: SpawnRequestFacts, charge: SpawnCharge, maximum: number): never {
this.hostCapRefused += 1
try {
this.options.log('host-terminal-cap-reached', {
charge,
declaredIntent: facts.declaredIntent,
terminals: this.host.terminals(this.state),
maxTerminals: this.host.maxTerminals
maxTerminals: maximum
})
} catch {
// Diagnostics must never change the refusal.
+2 -2
View File
@@ -34,8 +34,8 @@ export class TerminalSpawnHostShare {
return this.liveSessions.size + interactive + user + automatic
}
tryReserve(state: SpawnAdmissionState): boolean {
return this.ledger.tryReserve(this.share(state))
tryReserve(state: SpawnAdmissionState, maximum = this.maxTerminals): boolean {
return this.ledger.tryReserve(this.share(state), maximum)
}
recordAutomaticAdmit(now: number): void {
+3 -1
View File
@@ -3,6 +3,7 @@ import { createRuntimePtySpawnState } from './spawn-state'
import { localProvider } from '../provider/registry'
import {
getLocalAutomaticWorkStatus,
localTerminalLimit,
resumeLocalAutomaticWork
} from '../../../daemon/daemon-automatic-work-resume'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -59,7 +60,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
return adopted
},
spawn: async (args) => spawnPtyFromRuntimeController(deps, args),
// Automatic-work gates belong to the local provider; SSH runtimes own theirs.
// Local owner only: an SSH host's gate belongs to that host's own runtime.
terminalLimit: (update) => localTerminalLimit(localProvider, update),
getAutomaticWorkStatus: () => getLocalAutomaticWorkStatus(localProvider),
resumeAutomaticWork: () => resumeLocalAutomaticWork(localProvider),
write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind),
@@ -310,3 +310,13 @@ describe('terminal default shell', () => {
expect(normalizeLegacyProfile({ terminalDefaultShell: ' ' }).terminalDefaultShell).toBe('')
})
})
it('loads an existing profile without a terminal policy mirror and preserves its saved preferences', () => {
const loaded = normalizeLegacyProfile({
terminalFocusFollowsMouse: true,
machineName: 'Build server'
})
expect(loaded.terminalFocusFollowsMouse).toBe(true)
expect(loaded.machineName).toBe('Build server')
expect(loaded).not.toHaveProperty('maxTerminals')
})
+2 -21
View File
@@ -1,5 +1,5 @@
import { BROWSER_GOOGLE_COOKIE_CLEAR_CAPABILITY } from '../../shared/browser-runtime-capabilities'
import { OrcaRuntimeWithGetRuntimeId } from './workspaces/session-hosts/get-runtime-id'
import { OrcaRuntimeWithPtyController } from './orca-runtime-pty-controller'
import type { RuntimeDegradation, RuntimeStatus } from '../../shared/runtime-types'
import {
runtimeBrowserCommandsFactoryIsHeadless,
@@ -37,11 +37,6 @@ import { runtimeTerminalDegradation } from './native-terminal-availability'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
import type { RuntimeWorktreeLifecycleEvent } from './orca-runtime-core'
import { WORKTREE_CREATE_RESULT_TTL_MS } from './orca-runtime-core'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import type {
AutomaticWorkResumeOutcome,
AutomaticWorkStatus
} from '../../shared/host-spawn-admission'
import type { RuntimeNotifier } from './runtime-notifier-contract'
import type {
AutomationsChangedPayload,
@@ -67,7 +62,7 @@ type RuntimeStatusHost = {
): string[]
}
export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId {
export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithPtyController {
protected abstract pruneUnregisteredWorktreeScans(): void
private asRuntimeStatusHost(): RuntimeStatusHost {
@@ -185,20 +180,6 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime
return this.machineName.readyWithin(MACHINE_NAME_PUBLISH_WAIT_MS)
}
setPtyController(controller: RuntimePtyController | null): void {
// Write through the main-owned PTY registry so renderer reloads cannot drift handle/process identity.
this.ptyController = controller
}
/** Null when the local terminal owner has no fork gate (in-process fallback, older daemon). */
async getAutomaticWorkStatus(): Promise<AutomaticWorkStatus | null> {
return (await this.ptyController?.getAutomaticWorkStatus?.()) ?? null
}
async resumeAutomaticWork(): Promise<AutomaticWorkResumeOutcome> {
return (await this.ptyController?.resumeAutomaticWork?.()) ?? null
}
setNotifier(notifier: RuntimeNotifier | null): void {
this.notifier = notifier
if (notifier) {
@@ -0,0 +1,29 @@
import { OrcaRuntimeWithGetRuntimeId } from './workspaces/session-hosts/get-runtime-id'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import type {
AutomaticWorkResumeOutcome,
AutomaticWorkStatus
} from '../../shared/host-spawn-admission'
import type { TerminalLimitStatus, TerminalLimitUpdate } from '../../shared/terminal-limit-policy'
export abstract class OrcaRuntimeWithPtyController extends OrcaRuntimeWithGetRuntimeId {
setPtyController(controller: RuntimePtyController | null): void {
// Why: CLI terminal writes must go through the main-owned PTY registry
// instead of tunneling back through renderer IPC, or live handles could
// drift from the process they are supposed to control during reloads.
this.ptyController = controller
}
/** Null when the local terminal owner has no fork gate (in-process fallback, older daemon). */
async terminalLimit(update?: TerminalLimitUpdate): Promise<TerminalLimitStatus | null> {
return (await this.ptyController?.terminalLimit?.(update)) ?? null
}
async getAutomaticWorkStatus(): Promise<AutomaticWorkStatus | null> {
return (await this.ptyController?.getAutomaticWorkStatus?.()) ?? null
}
async resumeAutomaticWork(): Promise<AutomaticWorkResumeOutcome> {
return (await this.ptyController?.resumeAutomaticWork?.()) ?? null
}
}
@@ -1,3 +1,4 @@
import { TerminalLimitUpdateParams } from '../../../../shared/rpc-contract/terminal-limit-params'
import { TerminalDaemonRestartSeveredParams } from '../../../../shared/rpc-contract/terminal-daemon-params'
import {
readSeveredDaemonStatus,
@@ -6,6 +7,18 @@ import {
import { defineMethod } from '../core'
export const TERMINAL_DAEMON_METHODS = [
defineMethod({
name: 'terminalDaemon.limit',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.terminalLimit()
}),
defineMethod({
name: 'terminalDaemon.setLimit',
permission: 'host-admin',
params: TerminalLimitUpdateParams,
handler: async (params, { runtime }) => runtime.terminalLimit(params)
}),
defineMethod({
name: 'terminalDaemon.severedStatus',
permission: 'workspace',
@@ -1,3 +1,4 @@
import type { TerminalLimitStatus, TerminalLimitUpdate } from '../../shared/terminal-limit-policy'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import type { PtySpawnOutcome } from '../ipc/pty/spawn-outcome'
import type { RuntimePaneCreateClaim } from '../ipc/pty/pane/spawn-reservation'
@@ -135,6 +136,7 @@ export type RuntimePtyController = {
}
>
/** The local terminal owner's automatic-restore state; null when it has no fork gate. */
terminalLimit?(update?: TerminalLimitUpdate): Promise<TerminalLimitStatus | null>
getAutomaticWorkStatus?(): Promise<AutomaticWorkStatus | null>
resumeAutomaticWork?(): Promise<AutomaticWorkResumeOutcome>
write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean
+3 -11
View File
@@ -1,4 +1,6 @@
import type { Dir, Stats } from 'node:fs'
import type { Dir } from 'node:fs'
import { isSafeOwnedDirectory } from '../../shared/owned-private-directory'
export { isSafeOwnedDirectory } from '../../shared/owned-private-directory'
import { lstat, mkdir, opendir } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { removeTree } from '../../shared/windows-transient-lock-removal'
@@ -25,16 +27,6 @@ export async function ensureOwnedTempStagingRoot(stagingRoot: string): Promise<b
return isSafeOwnedDirectory(await lstat(stagingRoot))
}
export function isSafeOwnedDirectory(stats: Stats): boolean {
if (!stats.isDirectory() || stats.isSymbolicLink()) {
return false
}
if (typeof process.getuid !== 'function') {
return true
}
return stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700
}
export function isDirectChild(parent: string, candidate: string): boolean {
return dirname(resolve(candidate)) === resolve(parent)
}
@@ -1,3 +1,5 @@
import { TerminalLimitSetting } from './TerminalLimitSetting'
import type { SettingsHostScope } from './settings-host-scope'
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { TERMINAL_DEFAULT_WORD_SEPARATOR } from '../../../../shared/terminal-word-separator'
@@ -22,6 +24,7 @@ import { TerminalMacKeyboardSection } from './TerminalMacKeyboardSection'
import { translate } from '@/i18n/i18n'
type TerminalAdvancedSectionProps = {
hostScope?: SettingsHostScope
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
scrollbackMode: 'preset' | 'custom'
@@ -41,6 +44,7 @@ function toWordSeparatorSetting(draft: string): string | undefined {
}
export function TerminalAdvancedSection({
hostScope,
settings,
updateSettings,
scrollbackMode,
@@ -194,6 +198,8 @@ export function TerminalAdvancedSection({
/>
</SearchableSetting>
{hostScope ? <TerminalLimitSetting scope={hostScope} /> : null}
<SearchableSetting
title={translate('auto.components.settings.TerminalPane.4bebcc2b2c', 'Word Separators')}
description={translate(
@@ -0,0 +1,330 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TerminalLimitSetting } from './TerminalLimitSetting'
import { readTerminalLimit, saveTerminalLimit } from './terminal-limit-transport'
import type * as TerminalLimitTransport from './terminal-limit-transport'
import type { SettingsHostScope } from './settings-host-scope'
import type { TerminalLimitStatus } from '../../../../shared/terminal-limit-policy'
import { useAppStore } from '@/store'
import { i18n } from '@/i18n/i18n'
import ptCatalog from '@/i18n/locales/pt.json'
import { getTerminalAdvancedSearchEntries } from './terminal-advanced-platform-search'
import { matchesSettingsSearch } from './settings-search'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { callHostRoute } from '@/runtime/host-route-call'
import { advanceRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status-connection-generation'
vi.mock('@/runtime/host-route-call', () => ({ callHostRoute: vi.fn() }))
vi.mock('./terminal-limit-transport', () => ({
readTerminalLimit: vi.fn(),
saveTerminalLimit: vi.fn()
}))
const search = vi.hoisted(() => ({ query: '' }))
vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } }))
vi.mock('@/store', async () => {
const { useStore } = await import('zustand')
const { createRuntimeStatusTestStore } =
await import('@/store/slices/runtime-status.test-fixture')
const store = createRuntimeStatusTestStore()
return {
useAppStore: Object.assign(
(select: (state: unknown) => unknown) =>
useStore(store, (state) => select({ ...state, settingsSearchQuery: search.query })),
store
)
}
})
vi.mock('@/store/host-catalog-fencing', () => ({
captureHostIdentityFence: () => ({}),
isHostIdentityFenceCurrent: () => true
}))
const transport = await vi.importActual<typeof TerminalLimitTransport>('./terminal-limit-transport')
const local: SettingsHostScope = { target: { kind: 'local' }, available: true }
const base: TerminalLimitStatus = {
maxTerminals: 256,
systemPtyLimit: 511,
source: 'default',
editable: true,
daemonNonce: 'daemon-a'
}
let container: HTMLDivElement
let root: Root
beforeEach(async () => {
await i18n.changeLanguage('en')
search.query = ''
useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
vi.mocked(readTerminalLimit).mockReset().mockResolvedValue(base)
vi.mocked(saveTerminalLimit)
.mockReset()
.mockImplementation(async (_route, update) => ({
...base,
maxTerminals: update.maxTerminals,
source: 'saved'
}))
})
afterEach(() => {
act(() => root.unmount())
document.body.replaceChildren()
})
async function render(scope = local): Promise<void> {
await act(async () => root.render(<TerminalLimitSetting scope={scope} />))
}
function input(): HTMLInputElement {
const found = container.querySelector('input')
if (!found) {
throw new Error('No terminal limit input')
}
return found
}
async function type(value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input(), value)
input().dispatchEvent(new Event('input', { bubbles: true }))
})
}
async function enter(): Promise<void> {
await act(async () =>
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
)
}
it('clamps in the input, confirms Enter and blur saves once, and omits the caption and reset', async () => {
await render()
expect(input().value).toBe('256')
expect(input().max).toBe('511')
expect(container.textContent).toContain('System PTY limit: 511')
expect(container.textContent).not.toMatch(/Reset|Default:|1–511|Couldn’t read/)
await type('9999')
expect(input().value).toBe('511')
await enter()
await act(async () => input().dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
expect(saveTerminalLimit).toHaveBeenCalledTimes(1)
expect(saveTerminalLimit).toHaveBeenLastCalledWith(
{ target: { kind: 'local' }, at: 'local' },
{ maxTerminals: 511, daemonNonce: 'daemon-a' },
expect.any(AbortSignal)
)
await type('384')
await act(async () => input().dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
expect(input().value).toBe('384')
})
it('discards a late answer and draft when switching execution hosts', async () => {
let complete: ((value: TerminalLimitStatus) => void) | undefined
vi.mocked(readTerminalLimit).mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = resolve
})
)
await render()
expect(input().disabled).toBe(true)
expect(input().value).toBe('')
vi.mocked(readTerminalLimit).mockResolvedValue({
...base,
maxTerminals: 128,
systemPtyLimit: 256,
daemonNonce: 'daemon-b'
})
publish('server', true, 1)
await render({ target: { kind: 'environment', environmentId: 'server' }, available: true })
await act(async () => complete?.(base))
expect(input().value).toBe('128')
expect(input().max).toBe('256')
advanceRuntimeEnvironmentConnectionGeneration('server')
vi.mocked(readTerminalLimit).mockResolvedValue({ ...base, maxTerminals: 64 })
await render({ target: { kind: 'environment', environmentId: 'server' }, available: true })
expect(input().value).toBe('64')
})
it('retains a failed draft and ignores its late refresh after a successful retry', async () => {
await render()
let completeRefresh: ((value: TerminalLimitStatus) => void) | undefined
vi.mocked(readTerminalLimit).mockImplementationOnce(
() =>
new Promise((resolve) => {
completeRefresh = resolve
})
)
vi.mocked(saveTerminalLimit).mockRejectedValueOnce(new Error('disk unavailable'))
await type('384')
await enter()
expect(input().value).toBe('384')
expect(container.textContent).toContain('Couldn’t confirm')
expect(container.textContent).not.toContain('saved')
await type('400')
await enter()
await act(async () => completeRefresh?.(base))
expect(input().value).toBe('400')
expect(container.textContent).not.toContain('Couldn’t confirm')
})
it('keeps confirmed environment values above the bound and leaves old daemons uneditable', async () => {
vi.mocked(readTerminalLimit).mockResolvedValue({
...base,
maxTerminals: 600,
source: 'environment',
editable: false
})
await render()
expect(input().value).toBe('600')
expect(input().disabled).toBe(true)
expect(container.textContent).toContain('environment')
vi.mocked(readTerminalLimit).mockResolvedValue(null)
publish('old', true, 1)
await render({ target: { kind: 'environment', environmentId: 'old' }, available: true })
expect(input().value).toBe('')
expect(input().disabled).toBe(true)
expect(container.textContent).toContain('does not support')
})
const runtime: RuntimeStatus = {
runtimeId: 'same-runtime',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
}
function publish(id: string, live: boolean, checkedAt: number): void {
useAppStore.getState().setRuntimeEnvironmentStatus(id, {
status: live ? runtime : null,
checkedAt,
snapshot: {
environmentId: id,
pairingRevision: 1,
sequence: checkedAt,
checkedAt,
status: runtime,
verification: live ? 'verified' : 'unavailable',
transport: live ? 'ready' : 'disconnected'
}
})
}
it('disables on contact loss and reads again on a real same-runtime recovery publication', async () => {
publish('server', true, 1)
const scope: SettingsHostScope = {
target: { kind: 'environment', environmentId: 'server' },
available: true
}
await render(scope)
await type('384')
const generation = useAppStore
.getState()
.runtimeStatusByEnvironmentId.get('server')?.connectionGeneration
await act(async () => publish('server', false, 2))
expect(input().disabled).toBe(true)
expect(input().value).toBe('')
expect(container.textContent).toContain('disconnected')
vi.mocked(readTerminalLimit).mockResolvedValue({ ...base, maxTerminals: 64 })
await act(async () => publish('server', true, 3))
expect(
useAppStore.getState().runtimeStatusByEnvironmentId.get('server')?.connectionGeneration
).toBe(generation)
expect(input().disabled).toBe(false)
expect(input().value).toBe('64')
expect(readTerminalLimit).toHaveBeenCalledTimes(2)
})
it('finds and formats capacity in the UI language in the catalog and rendered row', async () => {
await i18n.changeLanguage('fr')
search.query = 'capacité'
expect(
getTerminalAdvancedSearchEntries().some((entry) => matchesSettingsSearch(search.query, entry))
).toBe(true)
vi.mocked(readTerminalLimit).mockResolvedValue({
...base,
maxTerminals: 2048,
systemPtyLimit: 4096,
savedUnreadable: true
})
const format = vi.spyOn(Number.prototype, 'toLocaleString')
try {
await render()
expect(input().value).toBe('2048')
expect(container.textContent).toContain('4\u202f096')
expect(container.querySelector('[role="status"]')?.textContent).toContain('2\u202f048')
expect(format).toHaveBeenCalledWith('fr')
expect(format).not.toHaveBeenCalledWith()
search.query = 'aucun-résultat'
await render()
expect(container.querySelector('input')).toBeNull()
expect(ptCatalog.settings.terminal.limit.savedUnreadable).toContain('Não foi possível ler')
} finally {
format.mockRestore()
}
})
it.each([
['Enter', 256],
['blur', 256],
['Enter', 384]
] as const)(
'repairs an unreadable saved limit with %s at %s, including the edited effective value',
async (event, maximum) => {
vi.mocked(readTerminalLimit).mockResolvedValue({ ...base, savedUnreadable: true })
await render()
expect(input().disabled).toBe(false)
expect(container.textContent).toContain('Using 256')
await type('')
await type(String(maximum))
await (event === 'Enter'
? enter()
: act(async () => input().dispatchEvent(new FocusEvent('focusout', { bubbles: true }))))
expect(saveTerminalLimit).toHaveBeenCalledWith(
expect.anything(),
{ maxTerminals: maximum, daemonNonce: 'daemon-a' },
expect.any(AbortSignal)
)
expect(input().value).toBe(String(maximum))
expect(container.textContent).not.toContain('Couldn’t read')
}
)
it('uses the selected-host transport and refuses unsupported capability without issuing a policy RPC', async () => {
const route = {
target: { kind: 'environment' as const, environmentId: 'old-server' },
at: 'local' as const
}
const signal = new AbortController().signal
vi.mocked(callHostRoute).mockResolvedValue({ capabilities: [] })
await expect(transport.readTerminalLimit(route, signal)).resolves.toBeNull()
expect(callHostRoute).toHaveBeenCalledExactlyOnceWith(route, 'status.get', undefined, { signal })
})
it('preserves unhealthy above-bound readback on untouched and canceled commits', async () => {
vi.mocked(readTerminalLimit).mockResolvedValue({
...base,
maxTerminals: 384,
systemPtyLimit: 128,
source: 'saved',
savedUnreadable: true
})
await render()
const blur = async () =>
act(async () => input().dispatchEvent(new FocusEvent('focusout', { bubbles: true })))
await enter()
await blur()
expect(input().value).toBe('384')
expect(saveTerminalLimit).not.toHaveBeenCalled()
await type('64')
await act(async () =>
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
)
await blur()
await enter()
expect(input().value).toBe('384')
expect(saveTerminalLimit).not.toHaveBeenCalled()
await type('64')
await blur()
await enter()
expect(input().value).toBe('64')
expect(saveTerminalLimit).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,271 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
terminalLimitInputMaximum,
type TerminalLimitStatus
} from '../../../../shared/terminal-limit-policy'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import { SettingsRow } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import type { SettingsHostScope } from './settings-host-scope'
import { readTerminalLimit, saveTerminalLimit } from './terminal-limit-transport'
import { useAppStore } from '@/store'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status-connection-generation'
import {
captureHostIdentityFence,
isHostIdentityFenceCurrent,
type HostIdentityFence
} from '@/store/host-catalog-fencing'
import type { HostRoute } from '@/runtime/runtime-client-target'
import { getIntlLocale, translate } from '@/i18n/i18n'
import { runtimeHostContactForEntry } from '../../../../shared/runtime-host-contact'
import { getTerminalLimitSearchKeywords } from './terminal-advanced-platform-search'
export function TerminalLimitSetting({ scope }: { scope: SettingsHostScope }): React.JSX.Element {
const environmentId = scope.target.kind === 'environment' ? scope.target.environmentId : null
const revision = useAppStore((state) => {
const host = state.runtimeEnvironments.find((item) => item.id === environmentId)
const pairing = host?.pairingRevision ?? host?.createdAt ?? 0
const connection = environmentId ? getRuntimeEnvironmentConnectionGeneration(environmentId) : 0
const contactEpoch =
state.runtimeStatusByEnvironmentId.get(environmentId ?? '')?.hostContactEpoch ?? 0
return `${pairing}:${connection}:${contactEpoch}`
})
const contacted = useAppStore(
(state) =>
environmentId === null ||
runtimeHostContactForEntry(state.runtimeStatusByEnvironmentId.get(environmentId)).verdict ===
'live'
)
const available = scope.available && contacted
return (
<TerminalLimitField
key={`${environmentId ?? 'local'}:${revision}:${available}`}
scope={{ ...scope, available }}
/>
)
}
function TerminalLimitField({ scope }: { scope: SettingsHostScope }): React.JSX.Element {
const route = useMemo<HostRoute>(() => ({ target: scope.target, at: 'local' }), [scope.target])
const [status, setStatus] = useState<TerminalLimitStatus | null>()
const [draft, setDraft] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const [busy, setBusy] = useState(false)
const controller = useRef<AbortController | null>(null)
const confirmedFence = useRef<HostIdentityFence | null>(null)
const saving = useRef(false)
const saveGeneration = useRef(0)
const [retry, setRetry] = useState(0)
useEffect(() => {
const request = new AbortController()
controller.current = request
const fence = captureHostIdentityFence(route)
setStatus(undefined)
setFailed(false)
if (scope.available) {
void readTerminalLimit(route, request.signal)
.then((value) => {
if (!request.signal.aborted && isHostIdentityFenceCurrent(fence)) {
confirmedFence.current = fence
setStatus(value)
}
})
.catch(() => {
if (!request.signal.aborted) {
setFailed(true)
}
})
}
return () => request.abort()
}, [route, scope.available, retry])
const maximum = terminalLimitInputMaximum(status?.systemPtyLimit ?? null)
const commit = async (): Promise<void> => {
const request = controller.current
if (
!status?.editable ||
draft === null ||
saving.current ||
!request ||
request.signal.aborted
) {
return
}
if (!confirmedFence.current || !isHostIdentityFenceCurrent(confirmedFence.current)) {
setFailed(true)
return
}
if (draft === '' || !Number.isFinite(Number(draft))) {
setDraft(null)
return
}
const value = Math.max(1, Math.min(maximum, Math.trunc(Number(draft))))
if (value === status.maxTerminals && !status.savedUnreadable) {
setDraft(null)
return
}
saving.current = true
const generation = ++saveGeneration.current
setBusy(true)
setFailed(false)
try {
const confirmed = await saveTerminalLimit(
route,
{ maxTerminals: value, daemonNonce: status.daemonNonce },
request.signal
)
if (!request.signal.aborted) {
if (!confirmed) {
throw new Error('Unsupported terminal service')
}
setStatus(confirmed)
setDraft(null)
}
} catch {
if (!request.signal.aborted) {
setFailed(true)
// Refresh a changed host bound, while retaining the user's draft for retry.
void readTerminalLimit(route, request.signal)
.then((fresh) => {
if (!request.signal.aborted && saveGeneration.current === generation) {
setStatus(fresh)
}
})
.catch(() => undefined)
}
} finally {
saving.current = false
if (!request.signal.aborted) {
setBusy(false)
}
}
}
const unavailable = !scope.available
? translate(
'settings.terminal.limit.disconnected',
'This host is disconnected. Reconnect to view or change its terminal limit.'
)
: status === null
? translate(
'settings.terminal.limit.unsupported',
'This host’s terminal service does not support changing this limit yet.'
)
: status?.editable === false && status.source !== 'environment'
? translate('settings.terminal.limit.unknown', 'Unavailable')
: status?.source === 'environment'
? translate(
'settings.terminal.limit.environment',
'This Orca instance has a terminal limit set by its environment. Remove the override on this host to edit it here.'
)
: null
const label = translate('settings.terminal.limit.label', 'Maximum running terminals')
return (
<SearchableSetting
title={label}
description={translate(
'settings.terminal.limit.description',
'Limits your running terminals across projects on this host.'
)}
keywords={getTerminalLimitSearchKeywords()}
>
<SettingsRow
alignTop
label={label}
labelId="terminal-running-limit-label"
description={
<>
<span className="block">
{translate(
'settings.terminal.limit.description',
'Limits your running terminals across projects on this host.'
)}
</span>
<span className="mt-2 block">
{translate('settings.terminal.limit.system', 'System PTY limit: {{value}}', {
value:
status === undefined && scope.available && !failed
? '…'
: (status?.systemPtyLimit?.toLocaleString(getIntlLocale()) ??
translate('settings.terminal.limit.unknown', 'Unavailable'))
})}
</span>
<span className="block">
{translate(
'settings.terminal.limit.shared',
'Shared with other terminal apps. This is total capacity, not free slots.'
)}
</span>
{unavailable ? <span className="mt-2 block">{unavailable}</span> : null}
{status?.savedUnreadable && !failed ? (
<span className="mt-2 block" role="status">
{translate(
'settings.terminal.limit.savedUnreadable',
'Couldn’t read this host’s saved terminal limit. Using {{value}}. Enter a value and press Enter to replace it.',
{ value: status.maxTerminals.toLocaleString(getIntlLocale()) }
)}
</span>
) : null}
{failed ? (
<span className="mt-2 block text-destructive" role="status">
{translate(
'settings.terminal.limit.failed',
'Couldn’t confirm the terminal limit. Try again.'
)}
</span>
) : null}
{failed && status === undefined ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => setRetry((value) => value + 1)}
>
{translate('settings.terminal.limit.retry', 'Retry')}
</Button>
) : null}
</>
}
control={
<div className="flex items-center gap-2">
<Input
id="terminal-running-limit"
aria-labelledby="terminal-running-limit-label"
type="number"
inputMode="numeric"
min={1}
max={maximum}
step={1}
disabled={!scope.available || !status?.editable || busy}
placeholder={status === undefined && !failed ? '…' : '—'}
value={draft ?? (status ? String(status.maxTerminals) : '')}
onChange={(event) => {
const raw = event.target.value
setDraft(
raw === '' ? '' : String(Math.max(1, Math.min(maximum, Math.trunc(Number(raw)))))
)
setFailed(false)
}}
onBlur={() => void commit()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void commit()
}
if (event.key === 'Escape') {
setDraft(null)
setFailed(false)
}
}}
className="number-input-clean w-24 tabular-nums"
/>
<span className="text-xs text-muted-foreground">
{translate('settings.terminal.limit.unit', 'terminals')}
</span>
</div>
}
/>
</SearchableSetting>
)
}
@@ -1,3 +1,4 @@
import type { SettingsHostScope } from './settings-host-scope'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -34,6 +35,7 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
type TerminalPaneProps = {
hostScope?: SettingsHostScope
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
scrollbackMode: 'preset' | 'custom'
@@ -53,6 +55,7 @@ type TerminalPaneProps = {
}
export function TerminalPane({
hostScope,
settings,
updateSettings,
scrollbackMode,
@@ -343,6 +346,7 @@ export function TerminalPane({
matchesSettingsSearch(searchQuery, getTerminalMacYenSearchEntries()))) ? (
<TerminalAdvancedSection
key="advanced"
hostScope={hostScope}
settings={settings}
updateSettings={updateSettings}
scrollbackMode={scrollbackMode}
@@ -21,6 +21,7 @@ export function renderTerminalSettingsSection(context: SettingsRenderContext): R
>
{view.isSectionMounted('terminal') ? (
<TerminalPane
hostScope={model.settingsHostScope}
settings={model.settings}
updateSettings={model.updateSettings}
scrollbackMode={model.scrollbackMode}
@@ -2,7 +2,25 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export function getTerminalLimitSearchKeywords(): string[] {
return [
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
...translateSearchKeyword('settings.terminal.limit.search.limit', 'limit'),
...translateSearchKeyword('settings.terminal.limit.search.maximum', 'maximum'),
...translateSearchKeyword('settings.terminal.limit.search.pty', 'pty'),
...translateSearchKeyword('settings.terminal.limit.search.capacity', 'capacity')
]
}
export const getTerminalAdvancedSearchEntries = createLocalizedCatalog(() => [
{
title: translate('settings.terminal.limit.label', 'Maximum running terminals'),
description: translate(
'settings.terminal.limit.description',
'Limits your running terminals across projects on this host.'
),
keywords: getTerminalLimitSearchKeywords()
},
{
title: translate('auto.components.settings.terminal.search.7674e758e1', 'Scrollback Rows'),
description: translate(
@@ -0,0 +1,27 @@
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import {
TERMINAL_LIMIT_RUNTIME_CAPABILITY,
type TerminalLimitStatus,
type TerminalLimitUpdate
} from '../../../../shared/terminal-limit-policy'
import { callHostRoute } from '@/runtime/host-route-call'
import type { HostRoute } from '@/runtime/runtime-client-target'
export async function readTerminalLimit(
route: HostRoute,
signal: AbortSignal
): Promise<TerminalLimitStatus | null> {
const host = await callHostRoute<RuntimeStatus>(route, 'status.get', undefined, { signal })
if (!host.capabilities?.includes(TERMINAL_LIMIT_RUNTIME_CAPABILITY)) {
return null
}
return callHostRoute(route, 'terminalDaemon.limit', undefined, { signal })
}
export function saveTerminalLimit(
route: HostRoute,
update: TerminalLimitUpdate,
signal: AbortSignal
): Promise<TerminalLimitStatus | null> {
return callHostRoute(route, 'terminalDaemon.setLimit', update, { signal })
}
+20
View File
@@ -571,6 +571,26 @@
"administratorSearchDescription": "See Orca’s Windows permissions or restart with administrator approval.",
"administratorApproval": "Windows will ask for approval. Use your usual account."
},
"limit": {
"label": "Maximum running terminals",
"description": "Limits your running terminals across projects on this host.",
"system": "System PTY limit: {{value}}",
"unknown": "Unavailable",
"shared": "Shared with other terminal apps. This is total capacity, not free slots.",
"disconnected": "This host is disconnected. Reconnect to view or change its terminal limit.",
"unsupported": "This host’s terminal service does not support changing this limit yet.",
"environment": "This Orca instance has a terminal limit set by its environment. Remove the override on this host to edit it here.",
"savedUnreadable": "Couldn’t read this host’s saved terminal limit. Using {{value}}. Enter a value and press Enter to replace it.",
"failed": "Couldn’t confirm the terminal limit. Try again.",
"retry": "Retry",
"unit": "terminals",
"search": {
"limit": "limit",
"maximum": "maximum",
"pty": "pty",
"capacity": "capacity"
}
},
"wordSeparators": {
"description": "Characters treated as word boundaries for double-click selection, including space.",
"noSpaceHint": "No space included — double-click will select across spaces."
+20
View File
@@ -106,6 +106,26 @@
"runAdministratorHelp": "Guía de administrador",
"standardStatus": "Usuario estándar",
"unknownAdministrator": "No se puede determinar"
},
"limit": {
"label": "Máximo de terminales en ejecución",
"description": "Limita las terminales en ejecución entre proyectos en este equipo.",
"system": "Límite de PTY del sistema: {{value}}",
"unknown": "No disponible",
"shared": "Compartido con otras aplicaciones de terminal. Es la capacidad total, no los espacios libres.",
"disconnected": "Este equipo está desconectado. Vuelve a conectarlo para ver o cambiar su límite de terminales.",
"unsupported": "El servicio de terminal de este equipo aún no permite cambiar este límite.",
"environment": "El entorno establece el límite de terminales de esta instancia de Orca. Elimina esa configuración en este equipo para editarlo aquí.",
"savedUnreadable": "No se pudo leer el límite de terminales guardado de este host. Se usa {{value}}. Introduce un valor y pulsa Intro para reemplazarlo.",
"failed": "No se pudo confirmar el límite de terminales. Inténtalo de nuevo.",
"retry": "Reintentar",
"unit": "terminales",
"search": {
"limit": "límite",
"maximum": "máximo",
"pty": "pty",
"capacity": "capacidad"
}
}
},
"appearance": {
+20
View File
@@ -326,6 +326,26 @@
"runAdministratorHelp": "Guide de l'administrateur",
"standardStatus": "Utilisateur standard",
"unknownAdministrator": "Impossible de déterminer"
},
"limit": {
"label": "Nombre maximal de terminaux actifs",
"description": "Limite les terminaux actifs de tous les projets sur cet hôte.",
"system": "Limite PTY du système : {{value}}",
"unknown": "Indisponible",
"shared": "Partagée avec les autres applications de terminal. Il s’agit de la capacité totale, pas des places libres.",
"disconnected": "Cet hôte est déconnecté. Reconnectez-le pour consulter ou modifier sa limite de terminaux.",
"unsupported": "Le service de terminal de cet hôte ne permet pas encore de modifier cette limite.",
"environment": "L’environnement définit la limite de terminaux de cette instance d’Orca. Supprimez cette configuration sur cet hôte pour la modifier ici.",
"savedUnreadable": "Impossible de lire la limite de terminaux enregistrée sur cet hôte. Valeur utilisée : {{value}}. Saisissez une valeur et appuyez sur Entrée pour la remplacer.",
"failed": "Impossible de confirmer la limite de terminaux. Réessayez.",
"retry": "Réessayer",
"unit": "terminaux",
"search": {
"limit": "limite",
"maximum": "maximum",
"pty": "pty",
"capacity": "capacité"
}
}
},
"appearance": {
+20
View File
@@ -106,6 +106,26 @@
"runAdministratorHelp": "管理者ガイド",
"standardStatus": "標準ユーザー",
"unknownAdministrator": "判定できません"
},
"limit": {
"label": "実行中のターミナルの上限",
"description": "このホスト上の全プロジェクトで実行中のターミナル数を制限します。",
"system": "システムのPTY上限: {{value}}",
"unknown": "取得できません",
"shared": "他のターミナルアプリと共有されます。空き数ではなく総容量です。",
"disconnected": "このホストは切断されています。再接続してターミナルの上限を確認または変更してください。",
"unsupported": "このホストのターミナルサービスは、まだ上限の変更に対応していません。",
"environment": "このOrcaインスタンスのターミナル上限は環境変数で設定されています。ここで編集するには、このホストの環境変数による設定を削除してください。",
"savedUnreadable": "このホストに保存されたターミナル上限を読み取れませんでした。{{value}} を使用しています。値を入力し、Enter キーを押して置き換えてください。",
"failed": "ターミナルの上限を確認できませんでした。もう一度お試しください。",
"retry": "再試行",
"unit": "ターミナル",
"search": {
"limit": "上限",
"maximum": "最大",
"pty": "pty",
"capacity": "容量"
}
}
},
"appearance": {
+20
View File
@@ -106,6 +106,26 @@
"runAdministratorHelp": "관리자 가이드",
"standardStatus": "표준 사용자",
"unknownAdministrator": "확인할 수 없음"
},
"limit": {
"label": "실행 중인 터미널 최대 개수",
"description": "이 호스트의 모든 프로젝트에서 실행 중인 터미널 수를 제한합니다.",
"system": "시스템 PTY 한도: {{value}}",
"unknown": "확인할 수 없음",
"shared": "다른 터미널 앱과 공유합니다. 남은 개수가 아닌 전체 용량입니다.",
"disconnected": "이 호스트의 연결이 끊겼습니다. 터미널 한도를 확인하거나 변경하려면 다시 연결하세요.",
"unsupported": "이 호스트의 터미널 서비스는 아직 한도 변경을 지원하지 않습니다.",
"environment": "이 Orca 인스턴스의 터미널 한도는 환경 변수로 설정되어 있습니다. 여기서 편집하려면 이 호스트에서 해당 설정을 제거하세요.",
"savedUnreadable": "이 호스트에 저장된 터미널 제한을 읽지 못했습니다. {{value}}을(를) 사용합니다. 값을 입력하고 Enter 키를 눌러 교체하세요.",
"failed": "터미널 한도를 확인하지 못했습니다. 다시 시도하세요.",
"retry": "다시 시도",
"unit": "터미널",
"search": {
"limit": "제한",
"maximum": "최대",
"pty": "pty",
"capacity": "용량"
}
}
},
"appearance": {
+3
View File
@@ -1465,6 +1465,9 @@
"openManageSessions": "Abrir Gerenciar sessões",
"administratorSearchDescription": "Veja as permissões do Orca no Windows ou reinicie com aprovação de administrador.",
"administratorApproval": "O Windows pedirá aprovação. Use sua conta habitual."
},
"limit": {
"savedUnreadable": "Não foi possível ler o limite de terminais salvo neste host. Usando {{value}}. Digite um valor e pressione Enter para substituí-lo."
}
},
"agents": {
+20
View File
@@ -106,6 +106,26 @@
"runAdministratorHelp": "管理员指南",
"standardStatus": "标准用户",
"unknownAdministrator": "无法确定"
},
"limit": {
"label": "运行中终端的最大数量",
"description": "限制此主机上所有项目中正在运行的终端数量。",
"system": "系统 PTY 上限:{{value}}",
"unknown": "无法获取",
"shared": "与其他终端应用共享。这是总容量,并非可用数量。",
"disconnected": "此主机已断开连接。请重新连接以查看或更改终端上限。",
"unsupported": "此主机的终端服务尚不支持更改此上限。",
"environment": "此 Orca 实例的终端上限由环境变量设置。要在此编辑,请先移除此主机上的相应环境设置。",
"savedUnreadable": "无法读取此主机已保存的终端上限。当前使用 {{value}}。输入数值并按 Enter 键即可替换。",
"failed": "无法确认终端上限。请重试。",
"retry": "重试",
"unit": "个终端",
"search": {
"limit": "限制",
"maximum": "最大值",
"pty": "pty",
"capacity": "容量"
}
}
},
"appearance": {
@@ -41,6 +41,20 @@ describe('readNodeFileSyncWithinLimit', () => {
expect(() => readNodeFileSyncWithinLimit(path, 1024)).toThrow(NodeFileReadTooLargeError)
})
it('reads the full zero-size sysctl value when later reads return EOF', () => {
const path = createTempFile('4096\n')
const originalStat = fs.fstatSync
vi.spyOn(fs, 'fstatSync').mockImplementation((...args) =>
Object.assign(originalStat(...args), { size: 0 })
)
const originalRead = fs.readSync
const read = vi
.spyOn(fs, 'readSync')
.mockImplementation((...args) => (read.mock.calls.length === 1 ? originalRead(...args) : 0))
expect(readNodeFileSyncWithinLimit(path, 1024).buffer.toString('utf8')).toBe('4096\n')
})
})
describe('regular file growth evidence', () => {
+24 -1
View File
@@ -11,14 +11,24 @@ type FileHandleOptions = {
initialSize?: number
readError?: Error
statError?: Error
firstReadOnly?: boolean
}
function createFileHandle({ content, initialSize, readError, statError }: FileHandleOptions) {
function createFileHandle({
content,
initialSize,
readError,
statError,
firstReadOnly
}: FileHandleOptions) {
const close = vi.fn().mockResolvedValue(undefined)
const read = vi.fn(async (target: Buffer, offset: number, length: number, position: number) => {
if (readError) {
throw readError
}
if (firstReadOnly && position > 0) {
return { bytesRead: 0, buffer: target }
}
const bytesRead = Math.min(length, Math.max(0, content.byteLength - position))
content.copy(target, offset, position, position + bytesRead)
return { bytesRead, buffer: target }
@@ -75,6 +85,19 @@ describe('readNodeFileWithinLimit', () => {
expect(handle.close).toHaveBeenCalledOnce()
})
it('reads the full sysctl value when later offsets return EOF', async () => {
const handle = createFileHandle({
content: Buffer.from('4096\n'),
initialSize: 0,
firstReadOnly: true
})
openMock.mockResolvedValue(handle)
const result = await readNodeFileWithinLimit('/proc/sys/kernel/pty/max', 1024)
expect(result.buffer.toString('utf8')).toBe('4096\n')
})
it('rejects growth beyond the limit without allocating the oversized content', async () => {
const handle = createFileHandle({ content: Buffer.from('growth'), initialSize: 3 })
openMock.mockResolvedValue(handle)
+4 -2
View File
@@ -109,7 +109,8 @@ export async function readNodeFileHandleWithinLimit(
}
validateSize(stats.size, maxBytes)
let buffer = Buffer.allocUnsafe(stats.size)
// Sysctl files report zero size and may return EOF after a partial first read.
let buffer = Buffer.allocUnsafe(stats.size || Math.min(MIN_GROWTH_BYTES, maxBytes))
let offset = 0
while (true) {
options.signal?.throwIfAborted()
@@ -169,7 +170,8 @@ export function readNodeFileSyncWithinLimit(
}
validateSize(stats.size, maxBytes)
let buffer = Buffer.allocUnsafe(stats.size)
// Sysctl files report zero size and may return EOF after a partial first read.
let buffer = Buffer.allocUnsafe(stats.size || Math.min(MIN_GROWTH_BYTES, maxBytes))
let offset = 0
while (true) {
while (offset < buffer.length) {
+11
View File
@@ -0,0 +1,11 @@
import type { Stats } from 'node:fs'
export function isSafeOwnedDirectory(stats: Stats): boolean {
if (!stats.isDirectory() || stats.isSymbolicLink()) {
return false
}
return (
typeof process.getuid !== 'function' ||
(stats.uid === process.getuid() && (stats.mode & 0o777) === 0o700)
)
}
+3
View File
@@ -610,6 +610,7 @@ import {
} from './structured-agent-session-queued-edit-params'
import { RenameParams } from './structured-agent-session-rename-params'
import { TerminalDaemonRestartSeveredParams } from './terminal-daemon-params'
import { TerminalLimitUpdateParams } from './terminal-limit-params'
import { TerminalAdoptOrphans } from './terminal-orphan-params'
import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params'
import {
@@ -1459,7 +1460,9 @@ export const RPC_PARAMS_BY_METHOD = {
'terminal.updateViewport': TerminalUpdateViewport,
'terminal.viewClaudeAgent': TerminalViewClaudeAgent,
'terminal.wait': TerminalWait,
'terminalDaemon.limit': null,
'terminalDaemon.restartSevered': TerminalDaemonRestartSeveredParams,
'terminalDaemon.setLimit': TerminalLimitUpdateParams,
'terminalDaemon.severedStatus': null,
'ui.get': null,
'ui.getJiraQuickFilters': null,
@@ -0,0 +1,7 @@
import { z } from 'zod'
import { MAX_CONFIGURED_TERMINALS } from '../terminal-limit-policy'
export const TerminalLimitUpdateParams = z.object({
maxTerminals: z.number().int().min(1).max(MAX_CONFIGURED_TERMINALS),
daemonNonce: z.string().min(1).max(128)
})
+26
View File
@@ -0,0 +1,26 @@
export const TERMINAL_LIMIT_RUNTIME_CAPABILITY = 'terminal.limit-policy.v1' as const
export const MAX_CONFIGURED_TERMINALS = 4096
export type TerminalLimitStatus = {
maxTerminals: number
systemPtyLimit: number | null
source: 'environment' | 'saved' | 'default'
savedUnreadable?: boolean
editable: boolean
daemonNonce: string
}
export type TerminalLimitUpdate = { maxTerminals: number; daemonNonce: string }
export function isConfiguredTerminalLimit(value: unknown): value is number {
return (
typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 1 &&
value <= MAX_CONFIGURED_TERMINALS
)
}
export function terminalLimitInputMaximum(systemPtyLimit: number | null): number {
return Math.min(MAX_CONFIGURED_TERMINALS, systemPtyLimit ?? MAX_CONFIGURED_TERMINALS)
}
@@ -1,3 +1,4 @@
import { TERMINAL_LIMIT_RUNTIME_CAPABILITY } from './terminal-limit-policy'
import { PROGRAM_STATUS_RUNTIME_CAPABILITY } from './program-status/protocol'
import { TERMINAL_QUICK_COMMAND_RUNTIME_CAPABILITIES } from './terminal-quick-command-capabilities'
@@ -30,6 +31,7 @@ export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY =
export const TERMINAL_SPLIT_RETRY_RUNTIME_CAPABILITY = 'terminal.split-retry.v1' as const
export const TERMINAL_RUNTIME_CAPABILITIES = [
TERMINAL_LIMIT_RUNTIME_CAPABILITY,
'terminal.binary-stream.v1',
PROGRAM_STATUS_RUNTIME_CAPABILITY,
'terminal.multiplex.v1',