mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user