diff --git a/src/main/daemon/daemon-automatic-work-guard.ts b/src/main/daemon/daemon-automatic-work-guard.ts index 62ea90b16e2..d28fb937893 100644 --- a/src/main/daemon/daemon-automatic-work-guard.ts +++ b/src/main/daemon/daemon-automatic-work-guard.ts @@ -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) => 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(), diff --git a/src/main/daemon/daemon-automatic-work-resume.ts b/src/main/daemon/daemon-automatic-work-resume.ts index 5b4a219073f..e8222468375 100644 --- a/src/main/daemon/daemon-automatic-work-resume.ts +++ b/src/main/daemon/daemon-automatic-work-resume.ts @@ -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 { + return (await localDaemons(provider)?.current.terminalLimit(update)) ?? null +} diff --git a/src/main/daemon/daemon-control-request.ts b/src/main/daemon/daemon-control-request.ts new file mode 100644 index 00000000000..bbd0a50a901 --- /dev/null +++ b/src/main/daemon/daemon-control-request.ts @@ -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, + onReply: () => void +): Promise { + 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) + }) + ) + } + } +} diff --git a/src/main/daemon/daemon-pty-daemon-recovery.ts b/src/main/daemon/daemon-pty-daemon-recovery.ts index 68e38336202..54f1f0dd48e 100644 --- a/src/main/daemon/daemon-pty-daemon-recovery.ts +++ b/src/main/daemon/daemon-pty-daemon-recovery.ts @@ -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 { + 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(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) diff --git a/src/main/daemon/daemon-request-deadline.ts b/src/main/daemon/daemon-request-deadline.ts index ce91375e776..54e89445b99 100644 --- a/src/main/daemon/daemon-request-deadline.ts +++ b/src/main/daemon/daemon-request-deadline.ts @@ -16,13 +16,23 @@ export async function awaitDaemonWorkWithinCallerDeadline( work: Promise, deadlineMs: number ): Promise { + return (await awaitDaemonValueWithinCallerDeadline(work, deadlineMs)).completed +} + +export async function awaitDaemonValueWithinCallerDeadline( + work: Promise, + deadlineMs: number +): Promise<{ completed: true; value: T } | { completed: false }> { void work.catch(() => {}) let timer: ReturnType | undefined try { return await Promise.race([ - work.then(() => true), - new Promise((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?.() }) ]) diff --git a/src/main/daemon/daemon-server-error-handling.test.ts b/src/main/daemon/daemon-server-error-handling.test.ts index b7834e8058e..1281b3a2b28 100644 --- a/src/main/daemon/daemon-server-error-handling.test.ts +++ b/src/main/daemon/daemon-server-error-handling.test.ts @@ -37,6 +37,7 @@ function createMockSubprocess(): SubprocessHandle { } type DaemonServerInternals = { endpoint: { + publish(bindPath: string): Promise 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() + } } ) }) diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts index a634a557235..f07f00ce255 100644 --- a/src/main/daemon/daemon-server-options.ts +++ b/src/main/daemon/daemon-server-options.ts @@ -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 diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 67a263d86b0..e322b91aba9 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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), now: () => Date.now() } @@ -244,7 +238,16 @@ export class DaemonServer { } async start(): Promise { - 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 { + this.terminalPolicy.dispose() this.endpoint.stopOwnershipWatch() this.stopStreamBacklogProbe() this.transientFactRelay.dispose() @@ -289,26 +293,19 @@ export class DaemonServer { clientId: string, request: DaemonRequest ): Promise { - 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) + ) } } diff --git a/src/main/daemon/daemon-terminal-limit-protocol.ts b/src/main/daemon/daemon-terminal-limit-protocol.ts new file mode 100644 index 00000000000..ee005047c04 --- /dev/null +++ b/src/main/daemon/daemon-terminal-limit-protocol.ts @@ -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 | undefined { + if (request.type !== 'terminalLimitStatus' && request.type !== 'setTerminalLimit') { + return undefined + } + const deadline = Date.now() + 2_000 + const work = async (): Promise => { + 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() +} diff --git a/src/main/daemon/daemon-terminal-limit.test.ts b/src/main/daemon/daemon-terminal-limit.test.ts new file mode 100644 index 00000000000..cb1b3620396 --- /dev/null +++ b/src/main/daemon/daemon-terminal-limit.test.ts @@ -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() + return { ...fs, writeFile: vi.fn(fs.writeFile), rm: vi.fn(fs.rm) } +}) + +const fs = await vi.importActual('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', undefined) + expect(before).toMatchObject({ + maxTerminals: 256, + systemPtyLimit: 511, + source: 'default', + editable: true, + savedUnreadable: true + }) + const repaired = await client.request('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', undefined) + let release!: () => void + let started!: () => void + const held = new Promise((done) => { + release = done + }) + const staging = new Promise((done) => { + started = done + }) + vi.mocked(writeFile).mockImplementationOnce(async (...args) => { + started() + await held + await fs.writeFile(...args) + }) + let cleaned!: () => void + const cleanup = new Promise((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() +}) diff --git a/src/main/daemon/host-terminal-ledger.ts b/src/main/daemon/host-terminal-ledger.ts index 1895b0c0ae3..271e65a403f 100644 --- a/src/main/daemon/host-terminal-ledger.ts +++ b/src/main/daemon/host-terminal-ledger.ts @@ -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) diff --git a/src/main/daemon/host-terminal-policy.test.ts b/src/main/daemon/host-terminal-policy.test.ts new file mode 100644 index 00000000000..ea2c5d18a76 --- /dev/null +++ b/src/main/daemon/host-terminal-policy.test.ts @@ -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() + return { ...fs, writeFile: vi.fn(fs.writeFile), rename: vi.fn(fs.rename), rm: vi.fn(fs.rm) } +}) + +const fs = await vi.importActual('node:fs/promises') + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((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 +): 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 { + 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>().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((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() + 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() + 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() + const read = vi.fn<() => Promise>().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() + 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() + const between = deferred() + read + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(between.promise) + .mockResolvedValue(384) + await vi.advanceTimersToNextTimerAsync() + const committing = deferred() + const release = deferred() + 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() + 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() + const release = deferred() + const staged = deferred() + const cleaned = deferred() + const cleanup = deferred() + let expiredTemporary: Parameters[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() + const release = deferred() + const cleaned = deferred() + let issuedTemporary: Parameters[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() + 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() + }) +}) diff --git a/src/main/daemon/host-terminal-policy.ts b/src/main/daemon/host-terminal-policy.ts new file mode 100644 index 00000000000..56e0c4ee7bf --- /dev/null +++ b/src/main/daemon/host-terminal-policy.ts @@ -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 { + 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: (load: () => Promise) => Promise +} + +async function withPolicyOperation( + isHostCurrent: () => boolean, + deadline: number, + work: (operation: PolicyOperation) => Promise +): Promise { + 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 + private timer?: ReturnType + private log: (event: string, details: Record) => 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 = 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 { + 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 { + 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 { + 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 { + 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 = 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) + } + }) + } +} diff --git a/src/main/daemon/system-pty-limit.ts b/src/main/daemon/system-pty-limit.ts new file mode 100644 index 00000000000..de6ec55c2e1 --- /dev/null +++ b/src/main/daemon/system-pty-limit.ts @@ -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 { + 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(0, 1) + constructor(private readonly probe: () => Promise = probeSystemPtyLimit) {} + + async read(): Promise { + const result = await awaitDaemonValueWithinCallerDeadline( + this.pending.run('system', () => this.probe().catch(() => null)), + Date.now() + 1_100 + ) + return result.completed ? result.value : null + } +} diff --git a/src/main/daemon/terminal-spawn-admission-mode.ts b/src/main/daemon/terminal-spawn-admission-mode.ts new file mode 100644 index 00000000000..e37a314859c --- /dev/null +++ b/src/main/daemon/terminal-spawn-admission-mode.ts @@ -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 `; 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' +} diff --git a/src/main/daemon/terminal-spawn-gate.ts b/src/main/daemon/terminal-spawn-gate.ts index 749e815a862..76920580898 100644 --- a/src/main/daemon/terminal-spawn-gate.ts +++ b/src/main/daemon/terminal-spawn-gate.ts @@ -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 { 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 = 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. diff --git a/src/main/daemon/terminal-spawn-host-share.ts b/src/main/daemon/terminal-spawn-host-share.ts index e44998e756f..d6668e6ca5e 100644 --- a/src/main/daemon/terminal-spawn-host-share.ts +++ b/src/main/daemon/terminal-spawn-host-share.ts @@ -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 { diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index ed91190958c..db8de341cf3 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -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), diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 402eb9f4a63..6fae9ae3524 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -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') +}) diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 768db488253..ef571dba5cc 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -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 { - return (await this.ptyController?.getAutomaticWorkStatus?.()) ?? null - } - - async resumeAutomaticWork(): Promise { - return (await this.ptyController?.resumeAutomaticWork?.()) ?? null - } - setNotifier(notifier: RuntimeNotifier | null): void { this.notifier = notifier if (notifier) { diff --git a/src/main/runtime/orca-runtime-pty-controller.ts b/src/main/runtime/orca-runtime-pty-controller.ts new file mode 100644 index 00000000000..11e160494ca --- /dev/null +++ b/src/main/runtime/orca-runtime-pty-controller.ts @@ -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 { + return (await this.ptyController?.terminalLimit?.(update)) ?? null + } + + async getAutomaticWorkStatus(): Promise { + return (await this.ptyController?.getAutomaticWorkStatus?.()) ?? null + } + + async resumeAutomaticWork(): Promise { + return (await this.ptyController?.resumeAutomaticWork?.()) ?? null + } +} diff --git a/src/main/runtime/rpc/methods/terminal-daemon.ts b/src/main/runtime/rpc/methods/terminal-daemon.ts index 28ddd1f42bc..3ec52a15a4c 100644 --- a/src/main/runtime/rpc/methods/terminal-daemon.ts +++ b/src/main/runtime/rpc/methods/terminal-daemon.ts @@ -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', diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index e67c6edbf23..901f61dcf31 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -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 getAutomaticWorkStatus?(): Promise resumeAutomaticWork?(): Promise write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean diff --git a/src/main/window/owned-temp-staging-root.ts b/src/main/window/owned-temp-staging-root.ts index a2e25b245bf..1f582aa38bc 100644 --- a/src/main/window/owned-temp-staging-root.ts +++ b/src/main/window/owned-temp-staging-root.ts @@ -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) => 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({ /> + {hostScope ? : null} + ({ 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('./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 { + await act(async () => root.render()) +} +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 { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input(), value) + input().dispatchEvent(new Event('input', { bubbles: true })) + }) +} +async function enter(): Promise { + 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) +}) diff --git a/src/renderer/src/components/settings/TerminalLimitSetting.tsx b/src/renderer/src/components/settings/TerminalLimitSetting.tsx new file mode 100644 index 00000000000..7351f702df9 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalLimitSetting.tsx @@ -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 ( + + ) +} + +function TerminalLimitField({ scope }: { scope: SettingsHostScope }): React.JSX.Element { + const route = useMemo(() => ({ target: scope.target, at: 'local' }), [scope.target]) + const [status, setStatus] = useState() + const [draft, setDraft] = useState(null) + const [failed, setFailed] = useState(false) + const [busy, setBusy] = useState(false) + const controller = useRef(null) + const confirmedFence = useRef(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 => { + 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 ( + + + + {translate( + 'settings.terminal.limit.description', + 'Limits your running terminals across projects on this host.' + )} + + + {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')) + })} + + + {translate( + 'settings.terminal.limit.shared', + 'Shared with other terminal apps. This is total capacity, not free slots.' + )} + + {unavailable ? {unavailable} : null} + {status?.savedUnreadable && !failed ? ( + + {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()) } + )} + + ) : null} + {failed ? ( + + {translate( + 'settings.terminal.limit.failed', + 'Couldn’t confirm the terminal limit. Try again.' + )} + + ) : null} + {failed && status === undefined ? ( + + ) : null} + + } + control={ +
+ { + 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" + /> + + {translate('settings.terminal.limit.unit', 'terminals')} + +
+ } + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index ed17c1ece1e..e4c31bd9c55 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -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) => 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()))) ? ( {view.isSectionMounted('terminal') ? ( [ + { + 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( diff --git a/src/renderer/src/components/settings/terminal-limit-transport.ts b/src/renderer/src/components/settings/terminal-limit-transport.ts new file mode 100644 index 00000000000..56f0bf041ef --- /dev/null +++ b/src/renderer/src/components/settings/terminal-limit-transport.ts @@ -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 { + const host = await callHostRoute(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 { + return callHostRoute(route, 'terminalDaemon.setLimit', update, { signal }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 33df1dd42f0..a1a0d2feb40 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 40c114bce1a..6157269257f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 15351631d2e..f6433dddfcc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d5ef18c55fd..f5bd862bc3c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fe81ec35c83..6177c939e86 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index f83fcaf06a2..42000100be1 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7843fb3325d..99a79e0346a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/node-bounded-file-reader-sync.test.ts b/src/shared/node-bounded-file-reader-sync.test.ts index 02a7d5c60dd..934160977a6 100644 --- a/src/shared/node-bounded-file-reader-sync.test.ts +++ b/src/shared/node-bounded-file-reader-sync.test.ts @@ -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', () => { diff --git a/src/shared/node-bounded-file-reader.test.ts b/src/shared/node-bounded-file-reader.test.ts index a01d8956d50..0b276741346 100644 --- a/src/shared/node-bounded-file-reader.test.ts +++ b/src/shared/node-bounded-file-reader.test.ts @@ -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) diff --git a/src/shared/node-bounded-file-reader.ts b/src/shared/node-bounded-file-reader.ts index 9be621dce27..8232670e903 100644 --- a/src/shared/node-bounded-file-reader.ts +++ b/src/shared/node-bounded-file-reader.ts @@ -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) { diff --git a/src/shared/owned-private-directory.ts b/src/shared/owned-private-directory.ts new file mode 100644 index 00000000000..b5c5dc977c9 --- /dev/null +++ b/src/shared/owned-private-directory.ts @@ -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) + ) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 52770e561bf..981b5dd8b52 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/rpc-contract/terminal-limit-params.ts b/src/shared/rpc-contract/terminal-limit-params.ts new file mode 100644 index 00000000000..942ad2eeca9 --- /dev/null +++ b/src/shared/rpc-contract/terminal-limit-params.ts @@ -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) +}) diff --git a/src/shared/terminal-limit-policy.ts b/src/shared/terminal-limit-policy.ts new file mode 100644 index 00000000000..91c9faed8fa --- /dev/null +++ b/src/shared/terminal-limit-policy.ts @@ -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) +} diff --git a/src/shared/terminal-runtime-capabilities.ts b/src/shared/terminal-runtime-capabilities.ts index e6d23b39d4e..987f018414d 100644 --- a/src/shared/terminal-runtime-capabilities.ts +++ b/src/shared/terminal-runtime-capabilities.ts @@ -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',