fix(runtime): attribute destructive close requests (#12238)

Attribute destructive runtime and daemon close diagnostics to the requesting client and exact target, record outcomes only after completion, and add reliability-gated attribution regressions.
This commit is contained in:
BingZ 2026-08-04 11:35:40 +08:00 committed by GitHub
parent 026ed921c1
commit 79d3c847bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 558 additions and 8 deletions

View File

@ -1815,13 +1815,15 @@
"invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.",
"oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-server-kill-attribution.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/runtime-close-attribution-topology.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/daemon/daemon-server-kill-attribution.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
"src/main/runtime/rpc/methods/session-tabs.test.ts",
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
"src/main/runtime/rpc/e2ee-channel.test.ts",
@ -1843,6 +1845,19 @@
"tests/e2e/daemon-generation-legacy-close-safety.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/daemon-server-kill-attribution.test.ts",
"assertions": [
"successful and failed daemon kill requests retain the authenticated control-client identity without claiming a failed kill succeeded"
]
},
{
"file": "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
"assertions": [
"remote, legacy, reconnect, stale, concurrent, cross-worktree, and unowned close spans retain authoritative runtime, target, requester, decision, and outcome identities without serializing the bearer credential",
"close attribution adds no terminal inventory scan or provider fanout"
]
},
{
"file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"assertions": [

View File

@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonServer } from './daemon-server'
import { SessionNotFoundError, type DaemonRequest } from './types'
type DaemonServerPrivate = {
host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void> }
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean; clientId: string }>>
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
}
describe('daemon kill attribution', () => {
let server: DaemonServer
let dir: string
afterEach(async () => {
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it('records the requesting control client in the session-killed log', async () => {
dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-'))
const killLog = { log: vi.fn(), close: vi.fn() }
server = new DaemonServer({
socketPath: join(dir, 'daemon.sock'),
tokenPath: join(dir, 'daemon.token'),
log: killLog,
spawnSubprocess: () => {
throw new Error('not used')
}
})
const daemon = server as unknown as DaemonServerPrivate
let finishKill!: () => void
const killFinished = new Promise<void>((resolve) => {
finishKill = resolve
})
vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished)
const request = daemon.routeRequest('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
})
await vi.waitFor(() => expect(daemon.host.kill).toHaveBeenCalledOnce())
expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything())
finishKill()
await request
expect(killLog.log).toHaveBeenCalledWith('session-killed', {
sessionId: 'agent-session',
immediate: true,
clientId: 'control-42'
})
})
it('attributes a failed kill without claiming the session was killed', async () => {
dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-'))
const killLog = { log: vi.fn(), close: vi.fn() }
server = new DaemonServer({
socketPath: join(dir, 'daemon.sock'),
tokenPath: join(dir, 'daemon.token'),
log: killLog,
spawnSubprocess: () => {
throw new Error('not used')
}
})
const daemon = server as unknown as DaemonServerPrivate
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused'))
await expect(
daemon.routeRequest('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
})
).rejects.toThrow('kill refused')
expect(killLog.log).toHaveBeenCalledWith('session-kill-failed', {
sessionId: 'agent-session',
immediate: true,
clientId: 'control-42'
})
expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything())
})
it('attributes a tolerated kill after canceling a pending spawn', async () => {
dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-'))
const killLog = { log: vi.fn(), close: vi.fn() }
server = new DaemonServer({
socketPath: join(dir, 'daemon.sock'),
tokenPath: join(dir, 'daemon.token'),
log: killLog,
spawnSubprocess: () => {
throw new Error('not used')
}
})
const daemon = server as unknown as DaemonServerPrivate
const pendingPreparation = { canceled: false, clientId: 'control-42' }
daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation]))
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session'))
await daemon.routeRequest('control-42', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
})
expect(pendingPreparation.canceled).toBe(true)
expect(killLog.log).toHaveBeenCalledWith('session-killed', {
sessionId: 'agent-session',
immediate: true,
clientId: 'control-42'
})
expect(killLog.log).not.toHaveBeenCalledWith('session-kill-failed', expect.anything())
})
})

View File

@ -935,18 +935,22 @@ export class DaemonServer {
request.payload.sessionId
)
this.lastInputAtBySessionId.delete(request.payload.sessionId)
this.log.log('session-killed', {
const attribution = {
sessionId: request.payload.sessionId,
immediate: request.payload.immediate === true
})
immediate: request.payload.immediate === true,
// Daemon control identity, not the paired-device bearer credential.
clientId
}
try {
await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
} catch (error) {
// Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done.
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
this.log.log('session-kill-failed', attribution)
throw error
}
}
this.log.log('session-killed', attribution)
return {}
}

View File

@ -43,7 +43,11 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
kind: 'client',
attributes: {
attribution: 'session-tab-close',
runtimeId: context.runtime.getRuntimeId(),
origin: context.clientKind ?? 'in-process',
deviceId: context.pairedDeviceId ?? 'in-process',
worktree: params.worktree,
tabId: params.tabId,
closeReason:
params.reason ??
(requiresIntent
@ -85,7 +89,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
kind: 'client',
attributes: {
attribution: 'session-tab-lifecycle-close',
runtimeId: context.runtime.getRuntimeId(),
origin: context.clientKind ?? 'in-process',
deviceId: context.pairedDeviceId ?? 'in-process',
worktree: params.worktree,
tabId: params.tabId,
terminal: params.terminal,
closeReason: params.reason,
connectionGeneration: context.connectionId ?? 'in-process',
requestId: context.requestId ?? 'in-process',

View File

@ -72,6 +72,7 @@ import {
type TerminalOutputSourceRange
} from '../../../../shared/terminal-output-source-range'
import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer'
import { withTerminalCloseAttribution } from '../terminal-close-attribution'
const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024
const TERMINAL_OUTPUT_FLUSH_MS = 5
@ -1499,15 +1500,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.close',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
close: await runtime.closeTerminal(params.terminal)
handler: async (params, context) => ({
close: await withTerminalCloseAttribution(
'terminal.close',
context,
'terminal',
params.terminal,
() => context.runtime.closeTerminal(params.terminal)
)
})
}),
defineMethod({
name: 'terminal.closeTab',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
close: await runtime.closeTerminalTab(params.terminal)
handler: async (params, context) => ({
close: await withTerminalCloseAttribution(
'terminal.closeTab',
context,
'terminal-tab',
params.terminal,
() => context.runtime.closeTerminalTab(params.terminal)
)
})
}),
defineMethod({

View File

@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer'
import type { OrcaRuntimeService } from '../orca-runtime'
import type { RpcRequest } from './core'
import { RpcDispatcher } from './dispatcher'
import { SESSION_TAB_METHODS } from './methods/session-tabs'
import { TERMINAL_METHODS } from './methods/terminal'
const BEARER_CLIENT_ID = 'secret-bearer-client-id'
type SpanRecord = {
name: string
attributes: Record<string, unknown>
exit: { _tag: string }
}
function request(id: string, method: string, params: unknown): RpcRequest {
return { id, authToken: 'test-token', method, params }
}
describe('runtime close attribution topology', () => {
const records: SpanRecord[] = []
beforeEach(() => {
records.length = 0
setActiveSink({
push: (record) => records.push(record as SpanRecord),
flush: vi.fn(),
close: vi.fn()
} satisfies TracerSink)
})
afterEach(() => _resetTracerForTests())
it('identifies the authoritative runtime and target for remote, stale, and legacy closes', async () => {
const closeMobileSessionTab = vi
.fn()
.mockResolvedValueOnce({ closed: true, refused: true, refusalReason: 'stale-terminal' })
.mockResolvedValue({ closed: true })
const runtime = {
getRuntimeId: () => 'runtime-owner-1',
closeMobileSessionTab,
refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(
request('stale-1', 'session.tabs.closeLifecycle', {
worktree: 'id:worktree-a',
tabId: 'tab-a',
reason: 'cleanup',
publicationEpoch: 'epoch-old',
terminal: 'terminal-old'
}),
vi.fn(),
{
clientId: BEARER_CLIENT_ID,
pairedDeviceId: 'device-1',
connectionId: 'connection-before-reconnect',
clientKind: 'runtime'
}
)
await dispatcher.dispatchStreaming(
request('legacy-1', 'session.tabs.close', {
worktree: 'id:worktree-b',
tabId: 'tab-b'
}),
vi.fn(),
{
clientId: BEARER_CLIENT_ID,
pairedDeviceId: 'device-1',
connectionId: 'connection-after-reconnect',
clientKind: 'runtime',
clientCapabilities: []
}
)
expect(records).toHaveLength(2)
expect(records[0]).toMatchObject({
name: 'runtime.session-tabs.close-lifecycle',
attributes: {
runtimeId: 'runtime-owner-1',
worktree: 'id:worktree-a',
tabId: 'tab-a',
terminal: 'terminal-old',
deviceId: 'device-1',
connectionGeneration: 'connection-before-reconnect',
requestId: 'stale-1',
decision: 'refused-stale-terminal'
},
exit: { _tag: 'Success' }
})
expect(records[1]).toMatchObject({
name: 'runtime.session-tabs.close',
attributes: {
runtimeId: 'runtime-owner-1',
worktree: 'id:worktree-b',
tabId: 'tab-b',
deviceId: 'device-1',
connectionGeneration: 'connection-after-reconnect',
requestId: 'legacy-1',
closeReason: 'legacy-runtime-user',
decision: 'allowed'
}
})
expect(JSON.stringify(records)).not.toContain(BEARER_CLIENT_ID)
})
it('keeps concurrent cross-worktree request and target identities distinct', async () => {
const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true })
const runtime = {
getRuntimeId: () => 'runtime-owner-2',
closeMobileSessionTab
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await Promise.all(
(
[
['request-a', 'device-a', 'connection-a', 'worktree-a', 'tab-a'],
['request-b', 'device-b', 'connection-b', 'worktree-b', 'tab-b']
] as const
).map(([id, deviceId, connectionId, worktree, tabId]) =>
dispatcher.dispatchStreaming(
request(id, 'session.tabs.close', {
worktree: `id:${worktree}`,
tabId,
reason: 'user'
}),
vi.fn(),
{ clientKind: 'runtime', pairedDeviceId: deviceId, connectionId }
)
)
)
expect(records.map((record) => record.attributes)).toEqual(
expect.arrayContaining([
expect.objectContaining({
runtimeId: 'runtime-owner-2',
worktree: 'id:worktree-a',
tabId: 'tab-a',
deviceId: 'device-a',
connectionGeneration: 'connection-a',
requestId: 'request-a'
}),
expect.objectContaining({
runtimeId: 'runtime-owner-2',
worktree: 'id:worktree-b',
tabId: 'tab-b',
deviceId: 'device-b',
connectionGeneration: 'connection-b',
requestId: 'request-b'
})
])
)
expect(closeMobileSessionTab).toHaveBeenCalledTimes(2)
})
it('records resolved terminal outcome and failed unowned requests without extra inventory', async () => {
const closeTerminal = vi
.fn()
.mockResolvedValueOnce({ handle: 'terminal-live', tabId: 'tab-live', ptyKilled: true })
.mockRejectedValueOnce(new Error('terminal_handle_stale'))
const listSessions = vi.fn()
const runtime = {
getRuntimeId: () => 'runtime-owner-3',
closeTerminal,
listSessions
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const context = {
clientKind: 'runtime' as const,
pairedDeviceId: 'device-terminal',
connectionId: 'connection-terminal'
}
await dispatcher.dispatchStreaming(
request('terminal-live-request', 'terminal.close', { terminal: 'terminal-live' }),
vi.fn(),
context
)
await dispatcher.dispatchStreaming(
request('terminal-stale-request', 'terminal.close', { terminal: 'terminal-stale' }),
vi.fn(),
context
)
expect(records[0]).toMatchObject({
attributes: {
runtimeId: 'runtime-owner-3',
terminal: 'terminal-live',
tabId: 'tab-live',
ptyKilled: true,
decision: 'allowed',
outcome: 'succeeded'
},
exit: { _tag: 'Success' }
})
expect(records[1]).toMatchObject({
attributes: {
runtimeId: 'runtime-owner-3',
terminal: 'terminal-stale',
decision: 'allowed',
outcome: 'failed'
},
exit: { _tag: 'Failure' }
})
expect(closeTerminal).toHaveBeenCalledTimes(2)
expect(listSessions).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer'
import type { OrcaRuntimeService } from '../orca-runtime'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
import { SESSION_TAB_METHODS } from './methods/session-tabs'
import { TERMINAL_METHODS } from './methods/terminal'
const BEARER_CLIENT_ID = 'secret-bearer-client-id'
type CapturingSink = TracerSink & { records: unknown[] }
function capturingSink(): CapturingSink {
const records: unknown[] = []
return {
records,
push: (record) => records.push(record),
flush: vi.fn(),
close: vi.fn()
}
}
function request(method: string, params: unknown): RpcRequest {
return { id: 'close-request-1', authToken: 'test-token', method, params }
}
describe('runtime close attribution', () => {
let sink: CapturingSink
beforeEach(() => {
sink = capturingSink()
setActiveSink(sink)
})
afterEach(() => {
_resetTracerForTests()
})
it.each([
['terminal.close', 'closeTerminal', 'terminal'],
['terminal.closeTab', 'closeTerminalTab', 'terminal-tab']
] as const)(
'records %s with the device identity and without the bearer credential',
async (method, call, targetKind) => {
const close = vi.fn().mockResolvedValue({ handle: 'term-1', ptyKilled: true })
const runtime = {
getRuntimeId: () => 'test-runtime',
[call]: close
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const replies: string[] = []
await dispatcher.dispatchStreaming(
request(method, { terminal: 'term-1' }),
(reply) => replies.push(reply),
{
clientId: BEARER_CLIENT_ID,
pairedDeviceId: 'device-uuid-123',
connectionId: 'connection-7',
clientKind: 'runtime'
}
)
expect(JSON.parse(replies[0]!)).toMatchObject({ ok: true })
expect(close).toHaveBeenCalledWith('term-1')
expect(sink.records).toEqual([
expect.objectContaining({
type: 'effect-span',
name: method,
attributes: expect.objectContaining({
attribution: 'terminal-close',
origin: 'runtime',
deviceId: 'device-uuid-123',
connectionGeneration: 'connection-7',
requestId: 'close-request-1',
targetKind,
terminal: 'term-1',
decision: 'allowed'
}),
exit: { _tag: 'Success' }
})
])
expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID)
}
)
it.each([
[
'session.tabs.close',
{ worktree: 'id:wt-1', tabId: 'tab-1', reason: 'user' },
'runtime.session-tabs.close'
],
[
'session.tabs.closeLifecycle',
{
worktree: 'id:wt-1',
tabId: 'tab-1',
reason: 'pty-exit',
publicationEpoch: 'epoch-1',
terminal: 'term-1'
},
'runtime.session-tabs.close-lifecycle'
]
] as const)(
'records the device identity for %s without the bearer credential',
async (method, params, spanName) => {
const runtime = {
getRuntimeId: () => 'test-runtime',
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatchStreaming(request(method, params), vi.fn(), {
clientId: BEARER_CLIENT_ID,
pairedDeviceId: 'device-uuid-123',
connectionId: 'connection-7',
clientKind: 'runtime'
})
expect(sink.records).toEqual([
expect.objectContaining({
name: spanName,
attributes: expect.objectContaining({ deviceId: 'device-uuid-123' })
})
])
expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID)
}
)
})

View File

@ -0,0 +1,50 @@
import { withSpan } from '../../observability/tracer'
import type { RuntimeTerminalClose } from '../../../shared/runtime-types'
import type { RpcContext } from './core'
type TerminalCloseMethod = 'terminal.close' | 'terminal.closeTab'
type TerminalCloseTargetKind = 'terminal' | 'terminal-tab'
export function withTerminalCloseAttribution(
method: TerminalCloseMethod,
context: Pick<
RpcContext,
'runtime' | 'clientKind' | 'pairedDeviceId' | 'connectionId' | 'requestId'
>,
targetKind: TerminalCloseTargetKind,
terminal: string,
close: () => Promise<RuntimeTerminalClose>
): Promise<RuntimeTerminalClose> {
return withSpan(
method,
async (span) => {
span.setAttribute('decision', 'allowed')
try {
const result = await close()
span.setAttribute('outcome', 'succeeded')
span.setAttribute('tabId', result.tabId)
span.setAttribute('ptyKilled', result.ptyKilled)
if (result.closeMode) {
span.setAttribute('closeMode', result.closeMode)
}
return result
} catch (error) {
span.setAttribute('outcome', 'failed')
throw error
}
},
{
kind: 'client',
attributes: {
attribution: 'terminal-close',
runtimeId: context.runtime.getRuntimeId(),
origin: context.clientKind ?? 'in-process',
deviceId: context.pairedDeviceId ?? 'in-process',
connectionGeneration: context.connectionId ?? 'in-process',
requestId: context.requestId ?? 'in-process',
targetKind,
terminal
}
}
)
}