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.
102 lines
4.9 KiB
TypeScript
102 lines
4.9 KiB
TypeScript
/** Linux native containment decisions over injected host observations. */
|
|
|
|
import type { LiteralArgvOptions } from './linux-scope.ts'
|
|
|
|
/** Host observations behind each decision; the runtime supplies the real checks and tests script them. */
|
|
export interface LinuxScopeObservations {
|
|
/** Run the deep transient-scope probe: literal-argv options when a scope can start, otherwise undefined. */
|
|
probe(): LiteralArgvOptions | undefined
|
|
/** Identify the process listening on the user manager's private socket, or undefined when none listens. */
|
|
managerListener(): string | undefined
|
|
/** Confirm the user manager before a launch under a positive verdict. */
|
|
confirmManager(): boolean
|
|
/** Identify the systemd-run binary scope launches resolve, or undefined when none resolves. */
|
|
launcherIdentity(): string | undefined
|
|
}
|
|
|
|
/** Event that made the selector probe: the first selection or one that invalidated the previous verdict. */
|
|
export type LinuxProbeTrigger = 'first' | 'scope-not-started' | 'new-listener' | 'launcher-changed'
|
|
|
|
/** One containment decision and the branch that produced it. */
|
|
export type LinuxScopeDecision =
|
|
| {
|
|
readonly kind: 'scope'
|
|
/** A probe found scope support, or a positive verdict confirmed the user manager. */
|
|
readonly reason: 'probe-available' | 'manager-confirmed'
|
|
readonly literalArgv: LiteralArgvOptions
|
|
/** Probe count when the verdict was made; pass it to scopeNotStarted for a failed launch. */
|
|
readonly generation: number
|
|
readonly probe: LinuxProbeTrigger | undefined
|
|
}
|
|
| {
|
|
readonly kind: 'fallback'
|
|
/**
|
|
* A probe found no scope support; a positive verdict failed to confirm the manager for this launch;
|
|
* or a negative verdict saw no listener or the listener present at the failed probe.
|
|
*/
|
|
readonly reason: 'probe-unavailable' | 'manager-unconfirmed' | 'no-listener' | 'same-listener'
|
|
readonly probe: LinuxProbeTrigger | undefined
|
|
}
|
|
|
|
type LinuxScopeState =
|
|
| { readonly kind: 'unprobed'; readonly trigger: LinuxProbeTrigger }
|
|
| { readonly kind: 'available'; readonly literalArgv: LiteralArgvOptions; readonly launcher: string | undefined }
|
|
| { readonly kind: 'unavailable'; readonly listener: string | undefined }
|
|
|
|
/**
|
|
* Provider-lifetime Linux scope selection. A probe runs on the first selection and again only after
|
|
* a scope launch never started its target, a different user-manager listener appears under a
|
|
* negative verdict, or the systemd-run binary changes under a positive one. A positive verdict
|
|
* confirms the user manager on every selection. Selection is synchronous, so a decision stays
|
|
* valid for the launch that follows it.
|
|
*/
|
|
export class LinuxScopeSelector {
|
|
private state: LinuxScopeState = { kind: 'unprobed', trigger: 'first' }
|
|
private generation = 0
|
|
|
|
/** @param observe - host checks consulted by each selection. */
|
|
constructor(private readonly observe: LinuxScopeObservations) {}
|
|
|
|
/**
|
|
* Choose containment for one eligible launch.
|
|
* @returns the decision and the branch that produced it.
|
|
*/
|
|
select(): LinuxScopeDecision {
|
|
const state = this.state
|
|
if (state.kind === 'unprobed') return this.probe(state.trigger)
|
|
if (state.kind === 'available') {
|
|
if (this.observe.launcherIdentity() !== state.launcher) return this.probe('launcher-changed')
|
|
return this.observe.confirmManager()
|
|
? { kind: 'scope', reason: 'manager-confirmed', literalArgv: state.literalArgv, generation: this.generation, probe: undefined }
|
|
: { kind: 'fallback', reason: 'manager-unconfirmed', probe: undefined }
|
|
}
|
|
const listener = this.observe.managerListener()
|
|
if (listener === undefined) return { kind: 'fallback', reason: 'no-listener', probe: undefined }
|
|
if (listener === state.listener) return { kind: 'fallback', reason: 'same-listener', probe: undefined }
|
|
return this.probe('new-listener')
|
|
}
|
|
|
|
/**
|
|
* Record that a scope launched under generation exited before its bootstrap started the target,
|
|
* so the next selection probes again. Launches from a superseded probe are ignored.
|
|
* @param generation - generation of the scope decision that launched it.
|
|
*/
|
|
scopeNotStarted(generation: number): void {
|
|
if (generation === this.generation) this.state = { kind: 'unprobed', trigger: 'scope-not-started' }
|
|
}
|
|
|
|
private probe(trigger: LinuxProbeTrigger): LinuxScopeDecision {
|
|
this.generation += 1
|
|
// Read before probing: a manager that starts during a failed probe then counts as a new listener.
|
|
const listener = this.observe.managerListener()
|
|
const launcher = this.observe.launcherIdentity()
|
|
const literalArgv = this.observe.probe()
|
|
if (literalArgv === undefined) {
|
|
this.state = { kind: 'unavailable', listener }
|
|
return { kind: 'fallback', reason: 'probe-unavailable', probe: trigger }
|
|
}
|
|
this.state = { kind: 'available', literalArgv, launcher }
|
|
return { kind: 'scope', reason: 'probe-available', literalArgv, generation: this.generation, probe: trigger }
|
|
}
|
|
}
|