Merge origin/master into worktree/sidebar-scrollbar-reveal
#1381 landed the bar's horizontal position; this branch decides when it is drawn. The e2e keeps both scenarios and the golden carries both palettes' pointer-state readings alongside the new edge-offset lines.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
|
||||
README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
|
||||
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
|
||||
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01
|
||||
|
||||
@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
|
||||
|
||||
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
|
||||
|
||||
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
@@ -49,6 +49,8 @@ dsh web --dump-config
|
||||
|
||||
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
|
||||
|
||||
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
|
||||
|
||||
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
|
||||
|
||||
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。
|
||||
|
||||
@@ -111,17 +111,19 @@
|
||||
# process out (the launchers patch the row disabled; config cannot disable
|
||||
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
|
||||
# random UUID; delete the file to reset the identity) as the Resource's
|
||||
# user.id. The exporter/processor values bound the shutdown drain to ~1s
|
||||
# against an unreachable collector: exporter.timeoutMillis is both the
|
||||
# per-attempt socket timeout and the retry deadline (1s effectively
|
||||
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
|
||||
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
|
||||
# is the processor's own cap on that one export cycle — the second bound
|
||||
# when the exporter's clock alone does not fire. Every CLI exit path drains it
|
||||
# by disposing the root on SIGINT/SIGTERM.
|
||||
# user.id. The exporter/processor values normally bound the shutdown drain
|
||||
# to ~1s against an unreachable collector: exporter.timeoutMillis is both
|
||||
# the per-attempt socket timeout and the retry deadline (1s effectively
|
||||
# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
|
||||
# (both explicit) makes the drain a single batch. The SDK awaits
|
||||
# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
|
||||
# shutdownTimeoutMillis is the load-bearing outer bound when a transport
|
||||
# promise never settles. Every CLI exit path drains it by disposing the root
|
||||
# on SIGINT/SIGTERM.
|
||||
- id: telemetry-otel
|
||||
name: '@deepseek-ai/dsh-session-telemetry-otel'
|
||||
config:
|
||||
shutdownTimeoutMillis: 3000
|
||||
exporter:
|
||||
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
|
||||
compression: gzip
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
import { createProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
|
||||
interface TurnOutcome {
|
||||
@@ -21,12 +22,12 @@ interface TurnOutcome {
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
|
||||
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
|
||||
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
|
||||
async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
|
||||
if (response.result.ok) return response.result.value
|
||||
const { code, message } = response.result.error
|
||||
process.stderr.write(`dsh: ${code}: ${message}\n`)
|
||||
await dispose()
|
||||
await shutdown()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -82,23 +83,16 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
port: 0,
|
||||
})
|
||||
const { ctx, port } = await entry.run()
|
||||
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
|
||||
// Signal exits must still dispose the tree: the composition mounts
|
||||
// exit-drained plugins (telemetry's queued tail and shutdown marker would
|
||||
// otherwise be lost), and Node's default signal exit skips disposal.
|
||||
let signalled = false
|
||||
const disposeAndExit = (code: number): void => {
|
||||
if (signalled) return
|
||||
signalled = true
|
||||
void dispose().finally(() => { process.exit(code) })
|
||||
}
|
||||
process.on('SIGTERM', () => { disposeAndExit(143) })
|
||||
process.on('SIGINT', () => { disposeAndExit(130) })
|
||||
// Normal completion and signals share one bounded drain. A signal received
|
||||
// during that drain escalates immediately instead of becoming a no-op.
|
||||
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(143) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
|
||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||
|
||||
const created = await unwrap(await api.sessions.create({}), dispose)
|
||||
const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
|
||||
|
||||
// Open the stream before prompting so no frame is lost — kept in this order
|
||||
// even though in-process delivery has no race, so the code survives a move
|
||||
@@ -111,11 +105,10 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: task }],
|
||||
}), dispose)
|
||||
}), () => shutdown.shutdown(1))
|
||||
|
||||
const outcome = await done
|
||||
process.stdout.write(outcome.text + '\n')
|
||||
abort.abort()
|
||||
await dispose()
|
||||
process.exit(outcome.reason === 'completed' ? 0 : 1)
|
||||
await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
|
||||
}
|
||||
|
||||
58
apps/cli/src/process-shutdown.ts
Normal file
58
apps/cli/src/process-shutdown.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
|
||||
|
||||
/** Maximum grace allowed for the application tree to dispose before process exit. */
|
||||
export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Process-exit controller shared by normal completion and Unix signal handlers. */
|
||||
export interface ProcessShutdown {
|
||||
/** Start or join graceful disposal before exiting with `code`. */
|
||||
shutdown(code: number): Promise<void>
|
||||
/** Start graceful disposal, or force exit when a shutdown is already running. */
|
||||
interrupt(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one process-exit controller around an application disposer.
|
||||
* @param dispose - Whole-application teardown that resolves at quiescence.
|
||||
* @param exit - Process exit boundary, replaceable by tests.
|
||||
* @param timeoutMs - Grace before forced exit, replaceable by tests.
|
||||
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
|
||||
*/
|
||||
export function createProcessShutdown(
|
||||
dispose: () => Promise<void>,
|
||||
exit: (code: number) => void = (code) => { process.exit(code) },
|
||||
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
|
||||
): ProcessShutdown {
|
||||
let pending: Promise<void> | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let exited = false
|
||||
|
||||
const exitOnce = (code: number): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
exit(code)
|
||||
}
|
||||
|
||||
const shutdown = (code: number): Promise<void> => {
|
||||
if (pending !== undefined) return pending
|
||||
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
|
||||
pending = Promise.resolve().then(dispose).then(
|
||||
() => { exitOnce(code) },
|
||||
() => { exitOnce(code) },
|
||||
)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
interrupt(code) {
|
||||
if (pending !== undefined) {
|
||||
exitOnce(code)
|
||||
return
|
||||
}
|
||||
void shutdown(code)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
import { createProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
// The shipped base plus the Web application's overlay.
|
||||
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
|
||||
@@ -118,17 +119,12 @@ export async function runWeb(
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
const resolvedLocalWebUrl = localWebUrl(ctx)
|
||||
|
||||
let exiting = false
|
||||
const shutdown = (code: number): void => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
|
||||
|
||||
// Install shutdown handling before publishing readiness: supervisors may
|
||||
// send a signal as soon as they observe the URL line.
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(0) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
|
||||
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
|
||||
// must name an address the /api trust fence was configured with.
|
||||
|
||||
18
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal file
18
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Test-only Cordis plugin whose disposer announces entry and never settles. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* Register a disposer that keeps process shutdown pending until it is forced.
|
||||
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
ctx.effect(() => async () => {
|
||||
clearInterval(keepAlive)
|
||||
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
|
||||
if (armFile === undefined || !existsSync(armFile)) return
|
||||
process.stderr.write('dsh-test: never-dispose started\n')
|
||||
await new Promise(() => {})
|
||||
})
|
||||
}
|
||||
121
apps/cli/tests/headless-shutdown.e2e.ts
Normal file
121
apps/cli/tests/headless-shutdown.e2e.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const neverDisposePlugin = pathToFileURL(
|
||||
fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
|
||||
).href
|
||||
|
||||
const POSIX_HEADLESS_PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(launch_env_json))
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
|
||||
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
|
||||
output = bytearray()
|
||||
marker_index = 0
|
||||
deadline = time.monotonic() + float(timeout_seconds)
|
||||
status = None
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EIO:
|
||||
raise
|
||||
chunk = b""
|
||||
if chunk:
|
||||
output.extend(chunk)
|
||||
while marker_index < len(markers) and markers[marker_index] in output:
|
||||
if marker_index == 0:
|
||||
open(os.path.join(cwd, "shutdown-armed"), "w").close()
|
||||
os.write(fd, b"\x03")
|
||||
marker_index += 1
|
||||
waited, candidate = os.waitpid(pid, os.WNOHANG)
|
||||
if waited == pid:
|
||||
status = candidate
|
||||
break
|
||||
|
||||
if status is None:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
sys.stdout.buffer.write(output)
|
||||
if marker_index != len(markers):
|
||||
sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
|
||||
sys.exit(124)
|
||||
actual_exit = os.waitstatus_to_exitcode(status)
|
||||
if actual_exit != 130:
|
||||
sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
|
||||
sys.exit(125)
|
||||
`
|
||||
|
||||
async function runHeadlessPtySmoke(): Promise<string> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
|
||||
try {
|
||||
const home = join(cwd, '.dsh')
|
||||
await mkdir(home, { recursive: true })
|
||||
await writeFile(join(home, 'config.yaml'), [
|
||||
'- insert:',
|
||||
' - id: never-dispose',
|
||||
` name: '${neverDisposePlugin}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['-p', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
|
||||
},
|
||||
})
|
||||
const timeoutMs = 15_000
|
||||
const result = await execa('python3', [
|
||||
'-c',
|
||||
POSIX_HEADLESS_PTY_DRIVER,
|
||||
launch.command,
|
||||
JSON.stringify(launch.args),
|
||||
JSON.stringify(launch.env),
|
||||
cwd,
|
||||
String(timeoutMs / 1_000),
|
||||
], {
|
||||
stdin: 'ignore',
|
||||
timeout: timeoutMs + 5_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
if (result.failed) {
|
||||
throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return result.stdout
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
|
||||
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
|
||||
const output = await runHeadlessPtySmoke()
|
||||
expect(output).toContain('dsh: observing at ')
|
||||
expect(output).toContain('dsh-test: never-dispose started')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
131
apps/cli/tests/process-shutdown.spec.ts
Normal file
131
apps/cli/tests/process-shutdown.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createProcessShutdown,
|
||||
PROCESS_SHUTDOWN_TIMEOUT_MS,
|
||||
} from '../src/process-shutdown.ts'
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<void>((accept, fail) => {
|
||||
resolve = accept
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('process shutdown', () => {
|
||||
it('exits once after graceful disposal resolves or rejects', async () => {
|
||||
const resolvedExit = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
|
||||
await resolved.shutdown(0)
|
||||
expect(resolvedExit).toHaveBeenCalledOnce()
|
||||
expect(resolvedExit).toHaveBeenCalledWith(0)
|
||||
|
||||
const rejectedExit = vi.fn()
|
||||
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
|
||||
await rejected.shutdown(1)
|
||||
expect(rejectedExit).toHaveBeenCalledOnce()
|
||||
expect(rejectedExit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('uses process.exit as the default process boundary', async () => {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve())
|
||||
|
||||
await shutdown.shutdown(7)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('forces exit when graceful disposal reaches its bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('honors a caller-supplied grace period', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
|
||||
disposal.resolve()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
shutdown.interrupt(130)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drains on the first signal and forces on the second signal', async () => {
|
||||
const disposal = deferred()
|
||||
const dispose = vi.fn(() => disposal.promise)
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(dispose, exit)
|
||||
|
||||
shutdown.interrupt(143)
|
||||
await Promise.resolve()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
|
||||
shutdown.interrupt(130)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
|
||||
disposal.resolve()
|
||||
await shutdown.shutdown(0)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces normal shutdown calls without treating them as escalation', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
|
||||
const first = shutdown.shutdown(0)
|
||||
const second = shutdown.shutdown(1)
|
||||
expect(second).toBe(first)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
|
||||
disposal.resolve()
|
||||
await first
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
153
apps/web/stress-tests/reasoning-chunks.stress.ts
Normal file
153
apps/web/stress-tests/reasoning-chunks.stress.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Opt-in browser stress reproduction for reasoning-stream renderer stalls.
|
||||
* The fixture emits 100,000 individual chunks through the normal async
|
||||
* carrier; the test measures event-loop and scheduled-interaction delay while
|
||||
* the assembled React surface keeps a collapsed Think row live.
|
||||
*/
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFailed } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
|
||||
|
||||
const CHUNK_COUNT = 100_000
|
||||
const CHUNKS_PER_INTERVAL = 128
|
||||
const CHUNK_INTERVAL_MS = 16
|
||||
const MAIN_THREAD_DELAY_BUDGET_MS = 250
|
||||
|
||||
interface ReasoningChunkStormState {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
}
|
||||
|
||||
interface StressProbe {
|
||||
intervalId: number
|
||||
intervalMs: number
|
||||
lastTickAt: number
|
||||
maxDelayMs: number
|
||||
samples: number
|
||||
interactionDueAt: number
|
||||
interactionHandledAt: number | null
|
||||
}
|
||||
|
||||
interface StressWindow extends Window {
|
||||
__fxTiming?: {
|
||||
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
|
||||
reasoningChunkStormState(): ReasoningChunkStormState | null
|
||||
}
|
||||
__reasoningStressProbe?: StressProbe
|
||||
}
|
||||
|
||||
it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
let page: Page | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
|
||||
page = await newEnglishPage(browser)
|
||||
const activePage = page
|
||||
await activePage.addInitScript(() => {
|
||||
localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
|
||||
})
|
||||
const tripwire = watchConsole(activePage)
|
||||
onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
|
||||
await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
||||
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fixture settings deliberately reject writes, so its welcome notice
|
||||
// cannot acknowledge. Hide only that test overlay; the assembled chat
|
||||
// tree beneath it remains mounted and exercises the production renderer.
|
||||
await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
|
||||
await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
|
||||
|
||||
await activePage.evaluate(() => {
|
||||
const intervalMs = 50
|
||||
const now = performance.now()
|
||||
const probe: StressProbe = {
|
||||
intervalId: 0,
|
||||
intervalMs,
|
||||
lastTickAt: now,
|
||||
maxDelayMs: 0,
|
||||
samples: 0,
|
||||
interactionDueAt: now + 1_000,
|
||||
interactionHandledAt: null,
|
||||
}
|
||||
probe.intervalId = window.setInterval(() => {
|
||||
const tickAt = performance.now()
|
||||
probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
|
||||
probe.lastTickAt = tickAt
|
||||
probe.samples++
|
||||
}, intervalMs)
|
||||
document.body.addEventListener('reasoning-stress-interaction', () => {
|
||||
probe.interactionHandledAt = performance.now()
|
||||
}, { once: true })
|
||||
window.setTimeout(() => {
|
||||
document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
|
||||
}, 1_000)
|
||||
;(window as StressWindow).__reasoningStressProbe = probe
|
||||
})
|
||||
|
||||
const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
|
||||
const hooks = (window as StressWindow).__fxTiming
|
||||
if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
|
||||
return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
|
||||
}, {
|
||||
chunkCount: CHUNK_COUNT,
|
||||
chunksPerInterval: CHUNKS_PER_INTERVAL,
|
||||
intervalMs: CHUNK_INTERVAL_MS,
|
||||
})
|
||||
|
||||
const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
|
||||
await liveThink.waitFor({ timeout: 60_000 })
|
||||
await expect.poll(async () => await activePage.evaluate(() => {
|
||||
const hooks = (window as StressWindow).__fxTiming
|
||||
return hooks?.reasoningChunkStormState()?.emitted ?? 0
|
||||
}), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
|
||||
await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
|
||||
|
||||
const report = await activePage.evaluate(() => {
|
||||
const win = window as StressWindow
|
||||
const probe = win.__reasoningStressProbe
|
||||
const state = win.__fxTiming?.reasoningChunkStormState()
|
||||
if (probe === undefined || state === undefined || state === null) {
|
||||
throw new Error('reasoning stress metrics unavailable')
|
||||
}
|
||||
window.clearInterval(probe.intervalId)
|
||||
const interactionDelayMs = probe.interactionHandledAt === null
|
||||
? null
|
||||
: probe.interactionHandledAt - probe.interactionDueAt
|
||||
return {
|
||||
chunkCount: state.chunkCount,
|
||||
chunksPerInterval: state.chunksPerInterval,
|
||||
intervalMs: state.intervalMs,
|
||||
emitted: state.emitted,
|
||||
maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
|
||||
interactionDelayMs,
|
||||
heartbeatSamples: probe.samples,
|
||||
}
|
||||
})
|
||||
process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
|
||||
|
||||
expect(report).toMatchObject({
|
||||
chunkCount: CHUNK_COUNT,
|
||||
chunksPerInterval: CHUNKS_PER_INTERVAL,
|
||||
intervalMs: CHUNK_INTERVAL_MS,
|
||||
emitted: CHUNK_COUNT,
|
||||
})
|
||||
expect(report.heartbeatSamples).toBeGreaterThan(0)
|
||||
const interactionDelayMs = report.interactionDelayMs
|
||||
if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
|
||||
expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
||||
expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
}
|
||||
}, 600_000)
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
|
||||
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
|
||||
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
|
||||
// ModuleLoader path (loadBundle) and proves the boot graph
|
||||
// assembles — staged activation across the immediately tier and the inject
|
||||
// layers, per-plugin CSS injection, and a rendered journey reaching chat
|
||||
// content from the keyless FixtureApiClient transport.
|
||||
@@ -91,11 +91,11 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
if (code === undefined) throw new Error(`missing built bundle ${url}`)
|
||||
;(0, eval)(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
|
||||
@@ -33,6 +33,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
const REPLAY_PACE_MS = 100
|
||||
|
||||
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -42,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
|
||||
@@ -128,11 +128,11 @@ describe('assembled search card', () => {
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
if (code === undefined) throw new Error(`missing built bundle ${url}`)
|
||||
;(0, eval)(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
|
||||
@@ -112,6 +112,10 @@ interface ListMetrics {
|
||||
overflows: boolean
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** Distance from the scrollbar's right edge to the sidebar edge. */
|
||||
scrollbarEdgeOffset: number
|
||||
/** Distance from the first row background's right edge to the sidebar edge. */
|
||||
rowEdgeInset: number
|
||||
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
|
||||
clientRight: number
|
||||
/** Border-box right edge in viewport coordinates. */
|
||||
@@ -139,6 +143,8 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const time = list.querySelector<HTMLElement>('[class*="time"]')
|
||||
if (time === null) throw new Error('no row relative-time element in the sidebar list')
|
||||
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error('no row in the sidebar list')
|
||||
// Each indirection variable is resolved through its own throwaway probe
|
||||
// appended to the list: `var()` substitution then happens where the list
|
||||
// sits in the cascade, which is the claim, and `color` normalizes whatever
|
||||
@@ -173,6 +179,9 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
const style = getComputedStyle(list)
|
||||
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
|
||||
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
|
||||
const listRect = list.getBoundingClientRect()
|
||||
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
|
||||
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
width: pseudoWidth,
|
||||
@@ -183,9 +192,11 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
token: resolve('--dsh-scrollbar-thumb'),
|
||||
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
band: list.getBoundingClientRect().width - list.clientWidth,
|
||||
clientRight: list.getBoundingClientRect().left + list.clientWidth,
|
||||
borderRight: list.getBoundingClientRect().right,
|
||||
band: listRect.width - list.clientWidth,
|
||||
scrollbarEdgeOffset: sidebarEdge - listRect.right,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
clientRight: listRect.left + list.clientWidth,
|
||||
borderRight: listRect.right,
|
||||
timeRight: time.getBoundingClientRect().right,
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
@@ -194,7 +205,28 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure only overflow and row inset, which remain observable when every
|
||||
* session is hidden under a collapsed workspace group.
|
||||
* @param page - the page under test.
|
||||
* @returns the list overflow state and first row's trailing inset.
|
||||
*/
|
||||
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
|
||||
return page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error('no row in the sidebar list')
|
||||
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
|
||||
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
|
||||
return {
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -257,6 +289,8 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
|
||||
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
|
||||
`- list overflows: ${String(metrics.overflows)}`,
|
||||
`- reserved band: ${String(metrics.band)}px`,
|
||||
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
|
||||
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
|
||||
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
|
||||
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
|
||||
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
|
||||
@@ -380,6 +414,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
// drawn over it. Removing the declaration makes it exactly 0. The value
|
||||
// itself is not pinned — it tracks `scrollbar-width` and the platform.
|
||||
expect(metrics.band).toBeGreaterThan(0)
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
@@ -426,6 +462,20 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the row background inset when overflow disappears', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
|
||||
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
await bucket.click()
|
||||
try {
|
||||
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
|
||||
} finally {
|
||||
await expandSeededSessions(page)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('renders the themed thumb through the WebKit path in both palettes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
|
||||
const light = await measureList(page)
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212)
|
||||
- list overflows: true
|
||||
- reserved band: 8px
|
||||
- scrollbar inset from the sidebar edge: 2px
|
||||
- row background inset from the sidebar edge: 12px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
@@ -30,6 +32,8 @@
|
||||
- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(84, 85, 87)
|
||||
- list overflows: true
|
||||
- reserved band: 8px
|
||||
- scrollbar inset from the sidebar edge: 2px
|
||||
- row background inset from the sidebar edge: 12px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
|
||||
@@ -20,6 +20,9 @@ function rejectStandaloneServe(): Plugin {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [rejectStandaloneServe(), react()],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
resolve: {
|
||||
// Workspace packages resolve to SOURCE: package.json exports point at lib
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
|
||||
Reference in New Issue
Block a user