Files
deepseek-harness/packages/subprocess/subprocess-local/tests/linux-scope-selector.spec.ts
T
imccyu da5e23e8e8 refactor(subprocess-local): decide Linux containment in a testable selector
The provider's Linux decisions now live in LinuxScopeSelector, a state machine
over injected observations: the deep probe, the user-manager listener, the
manager confirmation, and the systemd-run binary identity. Each decision
carries its reason and probe trigger, the runtime logs it at debug level, and
table-driven scenarios cover every transition.

- A positive verdict probes again when the systemd-run found through PATH
  changes device, inode, size, or modification time, so an in-place upgrade
  past v254 cannot reuse options that no longer keep argv literal.
- The listener is read before the probe, so a manager that starts during a
  failed probe counts as a new listener.
- The kernel socket table is decoded as UTF-8, matching runtime directories
  outside ASCII.
- LinuxScopeNotStartedError drops its unused code field.
2026-10-15 00:51:05 +08:00

242 lines
8.9 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { LinuxScopeSelector } from '../src/linux-scope-selector.ts'
import type { LinuxScopeDecision } from '../src/linux-scope-selector.ts'
/** Host state the selector observes; scenarios change it between launches. */
interface World {
/** What a deep probe returns now: literal-argv options, or undefined when no scope can start. */
probe: readonly string[] | undefined
/** Socket inode of the process listening on the user manager's private socket, or undefined. */
listener: string | undefined
/** Whether confirming the user manager succeeds now. */
confirm: boolean
/** Identity of the resolved systemd-run binary, or undefined when none resolves. */
launcher: string | undefined
}
type Step =
/** One eligible launch selects containment. */
| 'launch'
/** The host changes before the next launch. */
| { readonly set: Partial<World> }
/** The host changes while the next probe runs, after the selector read its listener and launcher. */
| { readonly duringProbe: Partial<World> }
/** A scope launched under this generation exited before its bootstrap started the target. */
| { readonly notStarted: number }
interface Scenario {
readonly name: string
readonly world: World
readonly steps: readonly Step[]
/** Summary of each launch's decision, in order. */
readonly decisions: readonly string[]
readonly probes: number
}
const OPTION = '--expand-environment=no'
const healthy: World = { probe: [OPTION], listener: 'manager-1', confirm: true, launcher: 'systemd-run-255' }
const absent: World = { probe: undefined, listener: undefined, confirm: false, launcher: 'systemd-run-245' }
function summary(decision: LinuxScopeDecision): string {
const probe = decision.probe === undefined ? '' : ` after ${decision.probe}`
return decision.kind === 'scope'
? `scope[${decision.literalArgv.join(' ')}]#${String(decision.generation)} ${decision.reason}${probe}`
: `fallback ${decision.reason}${probe}`
}
function run(initial: World, steps: readonly Step[]): { decisions: string[]; probes: number } {
const world = { ...initial }
let duringProbe: Partial<World> | undefined
let probes = 0
const selector = new LinuxScopeSelector({
probe: () => {
probes += 1
const result = world.probe
Object.assign(world, duringProbe)
duringProbe = undefined
return result
},
managerListener: () => world.listener,
confirmManager: () => world.confirm,
launcherIdentity: () => world.launcher,
})
const decisions: string[] = []
for (const step of steps) {
if (step === 'launch') decisions.push(summary(selector.select()))
else if ('set' in step) Object.assign(world, step.set)
else if ('duringProbe' in step) duringProbe = step.duringProbe
else selector.scopeNotStarted(step.notStarted)
}
return { decisions, probes }
}
const scenarios: readonly Scenario[] = [
{
name: 'no user manager: probe once, then fall back without probing',
world: absent,
steps: ['launch', 'launch', 'launch'],
decisions: ['fallback probe-unavailable after first', 'fallback no-listener', 'fallback no-listener'],
probes: 1,
},
{
name: 'systemd 254 and later: probe once, then confirm the manager per launch',
world: healthy,
steps: ['launch', 'launch', 'launch'],
decisions: [
`scope[${OPTION}]#1 probe-available after first`,
`scope[${OPTION}]#1 manager-confirmed`,
`scope[${OPTION}]#1 manager-confirmed`,
],
probes: 1,
},
{
name: 'systemd 236 to 253: scope launches carry no expansion option',
world: { ...healthy, probe: [], launcher: 'systemd-run-249' },
steps: ['launch', 'launch'],
decisions: ['scope[]#1 probe-available after first', 'scope[]#1 manager-confirmed'],
probes: 1,
},
{
name: 'a manager that stops and returns under a positive verdict falls back only while it is gone',
world: healthy,
steps: [
'launch',
{ set: { listener: undefined, confirm: false } },
'launch',
'launch',
{ set: { listener: 'manager-2', confirm: true } },
'launch',
],
decisions: [
`scope[${OPTION}]#1 probe-available after first`,
'fallback manager-unconfirmed',
'fallback manager-unconfirmed',
`scope[${OPTION}]#1 manager-confirmed`,
],
probes: 1,
},
{
name: 'a manager hung at the first probe is not probed again until a new manager listens',
world: { ...absent, listener: 'hung-manager' },
steps: ['launch', 'launch', 'launch', { set: { ...healthy, listener: 'manager-2' } }, 'launch', 'launch'],
decisions: [
'fallback probe-unavailable after first',
'fallback same-listener',
'fallback same-listener',
`scope[${OPTION}]#2 probe-available after new-listener`,
`scope[${OPTION}]#2 manager-confirmed`,
],
probes: 2,
},
{
name: 'a manager that hangs under a positive verdict costs one probe after its scope never starts',
world: { ...healthy, listener: 'hung-manager' },
steps: [
'launch',
'launch',
// The hung manager still listens, so confirmation passes but its scopes never start.
{ set: { probe: undefined } },
'launch',
{ notStarted: 1 },
'launch',
// A second launch from generation 1 fails late; the newer verdict stands.
{ notStarted: 1 },
'launch',
{ set: { listener: 'manager-2', probe: [OPTION] } },
'launch',
],
decisions: [
`scope[${OPTION}]#1 probe-available after first`,
`scope[${OPTION}]#1 manager-confirmed`,
`scope[${OPTION}]#1 manager-confirmed`,
'fallback probe-unavailable after scope-not-started',
'fallback same-listener',
`scope[${OPTION}]#3 probe-available after new-listener`,
],
probes: 3,
},
{
name: 'a scope that never starts under a healthy manager probes again and keeps the scope',
world: healthy,
steps: ['launch', { notStarted: 1 }, 'launch', 'launch'],
decisions: [
`scope[${OPTION}]#1 probe-available after first`,
`scope[${OPTION}]#2 probe-available after scope-not-started`,
`scope[${OPTION}]#2 manager-confirmed`,
],
probes: 2,
},
{
name: 'a manager that starts after a failed first probe is used without a restart',
world: absent,
steps: ['launch', 'launch', { set: { ...healthy, listener: 'manager-1' } }, 'launch', 'launch'],
decisions: [
'fallback probe-unavailable after first',
'fallback no-listener',
`scope[${OPTION}]#2 probe-available after new-listener`,
`scope[${OPTION}]#2 manager-confirmed`,
],
probes: 2,
},
{
name: 'a manager that starts during a failed probe counts as a new listener',
world: absent,
steps: [{ duringProbe: { ...healthy, listener: 'manager-1' } }, 'launch', 'launch'],
decisions: ['fallback probe-unavailable after first', `scope[${OPTION}]#2 probe-available after new-listener`],
probes: 2,
},
{
name: 'a systemd-run upgrade across v254 probes the literal-argv options again',
world: { ...healthy, probe: [], launcher: 'systemd-run-253' },
steps: ['launch', 'launch', { set: { probe: [OPTION], launcher: 'systemd-run-258' } }, 'launch', 'launch'],
decisions: [
'scope[]#1 probe-available after first',
'scope[]#1 manager-confirmed',
`scope[${OPTION}]#2 probe-available after launcher-changed`,
`scope[${OPTION}]#2 manager-confirmed`,
],
probes: 2,
},
{
name: 'a removed systemd-run turns a positive verdict negative',
world: healthy,
steps: ['launch', { set: { probe: undefined, launcher: undefined } }, 'launch', 'launch'],
decisions: [
`scope[${OPTION}]#1 probe-available after first`,
'fallback probe-unavailable after launcher-changed',
'fallback same-listener',
],
probes: 2,
},
]
describe('Linux scope selection', () => {
it.each(scenarios)('$name', ({ world, steps, decisions, probes }) => {
expect(run(world, steps)).toEqual({ decisions, probes })
})
it('consults only the observations each verdict needs', () => {
const observe = {
probe: vi.fn((): readonly string[] | undefined => undefined),
managerListener: vi.fn((): string | undefined => undefined),
confirmManager: vi.fn(() => true),
launcherIdentity: vi.fn((): string | undefined => 'systemd-run'),
}
const selector = new LinuxScopeSelector(observe)
selector.select()
selector.select()
expect(observe.probe).toHaveBeenCalledOnce()
expect(observe.managerListener).toHaveBeenCalledTimes(2)
expect(observe.launcherIdentity).toHaveBeenCalledOnce()
expect(observe.confirmManager).not.toHaveBeenCalled()
observe.probe.mockReturnValue([])
observe.managerListener.mockReturnValue('manager-1')
selector.select()
observe.managerListener.mockClear()
selector.select()
expect(observe.managerListener).not.toHaveBeenCalled()
expect(observe.confirmManager).toHaveBeenCalledOnce()
})
})