-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(core): add a live-session registry and qwen sessions ps
#8969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e0b9271
0957672
fe54a92
9edea3f
f1bb3a3
f305233
8a69962
e230980
656088b
9f2dbc1
fd768c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | ||
| import stringWidth from 'string-width'; | ||
| import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core'; | ||
|
|
||
| const listLiveSessions = vi.fn(); | ||
|
|
||
| vi.mock('@qwen-code/qwen-code-core', () => ({ | ||
| listLiveSessions: (...args: unknown[]) => listLiveSessions(...args), | ||
| })); | ||
|
|
||
| const stdout: string[] = []; | ||
| const stderr: string[] = []; | ||
|
|
||
| vi.mock('../../utils/stdioHelpers.js', () => ({ | ||
| writeStdoutLine: (line: string) => stdout.push(line), | ||
| writeStderrLine: (line: string) => stderr.push(line), | ||
| })); | ||
|
|
||
| const { psCommand, formatAge, NAME_COL, PID_COL, AGE_COL } = await import( | ||
| './ps.js' | ||
| ); | ||
|
|
||
| function record( | ||
| over: Partial<SessionRegistryRecord> = {}, | ||
| ): SessionRegistryRecord { | ||
| return { | ||
| schemaVersion: 1, | ||
| pid: 4242, | ||
| procStart: '123', | ||
| pidNs: null, | ||
| sessionId: 'sess-1', | ||
| cwd: '/w/app', | ||
| name: 'app-ab', | ||
| startedAt: Date.now() - 90_000, | ||
| qwenVersion: '1.0.0', | ||
| ...over, | ||
| }; | ||
| } | ||
|
|
||
| async function run(argv: Record<string, unknown>): Promise<void> { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| await (psCommand.handler as any)(argv); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| stdout.length = 0; | ||
| stderr.length = 0; | ||
| listLiveSessions.mockReset(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('formatAge', () => { | ||
| it('scales the unit with the magnitude', () => { | ||
| expect(formatAge(5_000)).toBe('5s'); | ||
| expect(formatAge(90_000)).toBe('1m'); | ||
| expect(formatAge(3 * 3600_000)).toBe('3h'); | ||
| expect(formatAge(50 * 3600_000)).toBe('2d'); | ||
| }); | ||
|
|
||
| it('clamps a record from the future to zero rather than showing a negative age', () => { | ||
| expect(formatAge(-10_000)).toBe('0s'); | ||
| }); | ||
|
|
||
| it('changes unit exactly at the boundary, never one step late', () => { | ||
| expect(formatAge(59_999)).toBe('59s'); | ||
| expect(formatAge(60_000)).toBe('1m'); | ||
| expect(formatAge(3_599_000)).toBe('59m'); | ||
| expect(formatAge(3_600_000)).toBe('1h'); | ||
| expect(formatAge(24 * 3_600_000 - 1_000)).toBe('23h'); | ||
| expect(formatAge(24 * 3_600_000)).toBe('1d'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('qwen sessions ps', () => { | ||
| it('prints a table of live sessions', async () => { | ||
| listLiveSessions.mockResolvedValue([record()]); | ||
| await run({ json: false }); | ||
|
|
||
| expect(stdout[0]).toMatch(/^NAME\s+PID\s+AGE\s+DIRECTORY$/); | ||
| expect(stdout[1]).toContain('app-ab'); | ||
| expect(stdout[1]).toContain('4242'); | ||
| expect(stdout[1]).toContain('/w/app'); | ||
| }); | ||
|
|
||
| it('puts every column at its declared offset', async () => { | ||
| // `toContain` cannot tell a laid-out table from four values joined by | ||
| // one space, and it cannot see the age at all. Pin the whole row. | ||
| vi.useFakeTimers(); | ||
| try { | ||
| vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); | ||
| listLiveSessions.mockResolvedValue([ | ||
| record({ startedAt: Date.now() - 5_000 }), | ||
| ]); | ||
| await run({ json: false }); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| } | ||
|
|
||
| expect(stdout[0]).toBe( | ||
| 'NAME'.padEnd(NAME_COL) + | ||
| 'PID'.padEnd(PID_COL) + | ||
| 'AGE'.padEnd(AGE_COL) + | ||
| 'DIRECTORY', | ||
| ); | ||
| expect(stdout[1]).toBe( | ||
| 'app-ab'.padEnd(NAME_COL) + | ||
| '4242'.padEnd(PID_COL) + | ||
| '5s'.padEnd(AGE_COL) + | ||
| '/w/app', | ||
| ); | ||
| expect([NAME_COL, PID_COL, AGE_COL]).toEqual([22, 9, 10]); | ||
| }); | ||
|
|
||
| it('says so plainly when nothing else is running', async () => { | ||
| listLiveSessions.mockResolvedValue([]); | ||
| await run({ json: false }); | ||
| expect(stdout).toEqual([ | ||
| 'No other interactive Qwen Code sessions are running.', | ||
| ]); | ||
| }); | ||
|
|
||
| it('emits one JSON object per line with no header', async () => { | ||
| listLiveSessions.mockResolvedValue([record(), record({ pid: 7 })]); | ||
| await run({ json: true }); | ||
|
|
||
| expect(stdout).toHaveLength(2); | ||
| expect(JSON.parse(stdout[0]).pid).toBe(4242); | ||
| expect(JSON.parse(stdout[1]).pid).toBe(7); | ||
| }); | ||
|
|
||
| it('emits each record as one whole line of JSON Lines', async () => { | ||
| // JSON Lines is line-delimited by definition: a pretty-printed record | ||
| // still round-trips through JSON.parse but breaks every consumer that | ||
| // reads it a line at a time, and drops no field on the way. | ||
| const rec = record(); | ||
| // Snapshotted before the run: the mock hands the handler the object | ||
| // itself, so computing the expectation afterwards would observe the | ||
| // very object the handler (mutatingly) emitted and could never catch | ||
| // an in-place field deletion. | ||
| const expected = JSON.stringify(rec); | ||
| listLiveSessions.mockResolvedValue([rec]); | ||
| await run({ json: true }); | ||
|
|
||
| expect(stdout).toEqual([expected]); | ||
| expect(stdout[0]).not.toContain('\n'); | ||
| }); | ||
|
|
||
| it('prints nothing on stdout for an empty JSON listing', async () => { | ||
| listLiveSessions.mockResolvedValue([]); | ||
| await run({ json: true }); | ||
| expect(stdout).toEqual([]); | ||
| }); | ||
|
|
||
| it('neutralizes control sequences coming from another process record', async () => { | ||
| listLiveSessions.mockResolvedValue([ | ||
|
Comment on lines
+163
to
+164
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This test embeds raw invisible 0x1B (ESC) bytes in the source — one in the fixture string (line 165) and one in the assertion (line 170) — instead of the escaped form the sibling 中文说明该测试在源码中嵌入了原始的不可见 0x1B(ESC)字节——fixture 字符串(第 165 行)一个、断言(第 170 行)一个——而没有像同目录 — qwen3.8-max via Qwen Code /review (v0.21.10) |
||
| record({ name: 'ev\x1b[31mil\r', cwd: '/w/a\nb\tc' }), | ||
| ]); | ||
| await run({ json: false }); | ||
|
|
||
| const row = stdout[1]; | ||
| expect(row).not.toContain('\x1b'); | ||
| expect(row).not.toContain('\r'); | ||
| expect(row).not.toContain('\n'); | ||
|
Comment on lines
+171
to
+172
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The TAB half of ps.ts's cell sanitizer ( record({ name: 'ev\u001b[31mil\r', cwd: '/w/a\nb\tc' }),
// ...
expect(row).not.toContain('\t');— qwen3.8-max via Qwen Code /review (v0.21.10) |
||
| // sanitizeTerminalText deliberately preserves TAB for multi-line | ||
| // render sites; the one-line table cell drops it on top — a literal | ||
| // TAB in a cwd (legal in POSIX filenames) would otherwise expand to | ||
| // the next tab stop and misalign every column after AGE. | ||
| expect(row).not.toContain('\t'); | ||
| }); | ||
|
|
||
| it('strips bidi overrides that would reorder the rendered row', async () => { | ||
| listLiveSessions.mockResolvedValue([ | ||
| record({ name: 'a\u202Eb', cwd: '/w/\u202Dsafe\u2069' }), | ||
| ]); | ||
| await run({ json: false }); | ||
|
|
||
| expect(stdout[1]).not.toMatch(/[\u202A-\u202E\u2066-\u2069]/); | ||
| expect(stdout[1]).toContain('/w/safe'); | ||
| }); | ||
|
|
||
| it('emits --json values raw, leaving terminal sanitization to the consumer', async () => { | ||
| // The contract the docs state: JSON output is data, not display. | ||
| // Bidi overrides that the table path strips must round-trip here — | ||
| // sanitizing them would rewrite the recorded path for every tooling | ||
| // consumer and diverge from the sibling `sessions list --json`. | ||
| listLiveSessions.mockResolvedValue([record({ cwd: '/w/\u202Ereorder' })]); | ||
| await run({ json: true }); | ||
|
|
||
| expect(JSON.parse(stdout[0]).cwd).toBe('/w/\u202Ereorder'); | ||
| }); | ||
|
|
||
| it('truncates an over-long name instead of breaking the columns', async () => { | ||
| listLiveSessions.mockResolvedValue([record({ name: 'x'.repeat(80) })]); | ||
| await run({ json: false }); | ||
| expect(stdout[1]).toContain('\u2026'); | ||
| expect(stdout[1]).toContain('4242'); | ||
| }); | ||
|
|
||
| it('truncates the name two cells short of its column, leaving a gutter', async () => { | ||
| // The gutter is what keeps a maximally long name from touching the PID | ||
| // beside it; truncating to the full column width would remove it. | ||
| listLiveSessions.mockResolvedValue([record({ name: 'x'.repeat(80) })]); | ||
| await run({ json: false }); | ||
|
|
||
| expect(stdout[1].slice(0, NAME_COL)).toBe(`${'x'.repeat(19)}\u2026 `); | ||
| }); | ||
|
|
||
| it('declares --json as a boolean that is off by default', async () => { | ||
| const options: Record<string, unknown> = {}; | ||
| const yargs = { | ||
| option: vi.fn((key: string, config: unknown) => { | ||
| options[key] = config; | ||
| return yargs; | ||
| }), | ||
| }; | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| (psCommand.builder as any)(yargs); | ||
|
|
||
| expect(psCommand.command).toBe('ps'); | ||
| expect(options['json']).toMatchObject({ type: 'boolean', default: false }); | ||
| }); | ||
|
|
||
| it('keeps a CJK name inside its column instead of shifting the row', async () => { | ||
| listLiveSessions.mockResolvedValue([record({ name: '项目'.repeat(20) })]); | ||
| await run({ json: false }); | ||
|
|
||
| // Padding is measured in terminal cells, not code units: a 2-cell CJK | ||
| // character must not push the PID column one cell right per character. | ||
| const row = stdout[1]; | ||
| expect(stringWidth(row.slice(0, row.indexOf('4242')))).toBe(22); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The docs promise machine-local listing and dead-only sweeping that tokenless platforms cannot honor when the home directory is shared across machines: on macOS/Windows
procStartandpidNsare bothnull, so inlistLiveSessionsthe namespace check passes (null === null), the boot-id guard is vacuous (recordBootIdderives null), andisSameProcessdegrades to barekill(pid, 0). Probe-verified against a model of a tokenless-platform reader: a machine-A-shaped record (pidNs: null, procStart: null) is unlinked by machine B's sweep (recordSurvives=false); the flip arm with a Linux reader (an identity to compare) keeps it; with a live-PID collision the foreign session is instead listed as local with its cwd/name. Both outcomes contradict "on this machine right now" and "Records left behind by a killed session are swept as they are found" four lines below. The module docstring documents the single-machine assumption for tokenless platforms; this page does not. — Concrete cost: two Macs sharing one NFS/roaming home — eachqwen sessions psdestroys the other's live registration (the victim session vanishes until restart) or lists the other's cwd/name as local. Distinct from theunregisterSessionrace findings: this is the list-path sweep with no identity to compare, so the sweep's re-read guard confirms the foreign record and still unlinks.中文说明
[Suggestion] 文档承诺的"本机列表"与"只清扫已死记录"在无启动令牌(tokenless)平台、且 home 目录跨机器共享时无法兑现:macOS/Windows 上
procStart与pidNs均为null,于是listLiveSessions中命名空间检查通过(null === null)、boot-id 守卫空转(recordBootId推出为 null)、isSameProcess退化为裸kill(pid, 0)。已针对 tokenless 平台读取方的模型探针验证:一条机器 A 形态的记录(pidNs: null, procStart: null)会被机器 B 的清扫 unlink(recordSurvives=false);换成拥有身份可比对的 Linux 读取方的翻转臂则记录存活;若本机恰好有同 PID 存活进程,外部会话反而被当作本机会话列出(带着它的 cwd/name)。两种结果都与"on this machine right now"及下方第四行"Records left behind by a killed session are swept as they are found"矛盾。模块 docstring 已为 tokenless 平台声明单机假设;本页面没有。具体代价:两台共享同一 NFS/漫游 home 的 Mac——每次qwen sessions ps都会摧毁对方的存活注册(受害会话在重启前消失),或把对方的 cwd/name 当成本机会话列出。与unregisterSession的竞态发现不同:这是枚举清扫路径上没有身份可比对的情形,清扫的 re-read 守卫会确认该外部记录然后照样 unlink。— qwen3.8-max via Qwen Code /review (v0.21.11)