From 229d4f9c545dbca3a0770d4769592908177fd193 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Wed, 9 Sep 2026 16:59:24 -0400 Subject: [PATCH 1/2] fix(development): serialize Linux lifecycle and reuse healthy snapshots --- .../commands/development-support/lifecycle.ts | 29 ++++++++++ src/cli/commands/development.ts | 16 +++++- tests/support/lifecycle-worker.ts | 7 +++ .../development/lifecycle-entrypoints.test.ts | 44 ++++++++++++++ .../development/lifecycle.test.ts | 57 +++++++++++++++++++ .../managed-development-container.test.ts | 4 +- 6 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/development-support/lifecycle.ts create mode 100644 tests/support/lifecycle-worker.ts create mode 100644 tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts create mode 100644 tests/unit/command-boundary/development/lifecycle.test.ts diff --git a/src/cli/commands/development-support/lifecycle.ts b/src/cli/commands/development-support/lifecycle.ts new file mode 100644 index 00000000..59ad62fb --- /dev/null +++ b/src/cli/commands/development-support/lifecycle.ts @@ -0,0 +1,29 @@ +import { resolve } from 'node:path'; +import { withOsCustodyLock } from '@treeseed/deployment/security/custody'; +import { developmentStateRoot } from '../development-cli-selection.js'; + +/** Shared builds and current.json cross session boundaries. Lock the Linux + * operator's lifecycle, not a time-limited development selection. Deployment + * owns the kernel-lock implementation; process death releases its descriptor. + */ +export function withDevelopmentLifecycle(env: NodeJS.ProcessEnv, action: () => Promise): Promise { + if (process.platform !== 'linux') return action(); + return withOsCustodyLock(resolve(developmentStateRoot(env), 'lifecycle'), action, 60); +} + +/** Reusing an immutable snapshot is not permission to overwrite its contents. + * Source changes enter through explicit restart/rebuild, not another use/resume. + */ +export function managedContainerAlreadyReady(value: unknown, sessionId: string, targetId: string): boolean { + const result = value as { registered?: unknown; state?: unknown }; + if (result?.registered === false) return false; + if (result?.registered !== true || typeof result.state !== 'string') throw new Error('Managed runtime status is invalid.'); + let rows: Array<{ Name?: string; State?: string; Health?: string }>; + try { rows = result.state.trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); } + catch { throw new Error('Managed runtime status is invalid.'); } + if (rows.length !== 1 || rows[0]?.Name !== `treeseed-${sessionId}-api-${targetId}`) + throw new Error('Managed runtime identity is inconsistent; inspect the session before restarting.'); + if (rows[0].State !== 'running' || rows[0].Health !== 'healthy') + throw new Error('Managed runtime is not healthy; use dev restart to drain and recover it.'); + return true; +} diff --git a/src/cli/commands/development.ts b/src/cli/commands/development.ts index 2e6e7eb9..96d6bdb2 100644 --- a/src/cli/commands/development.ts +++ b/src/cli/commands/development.ts @@ -14,6 +14,7 @@ import { runHostDevelopment } from './development-support/host-runtime.js'; import { applyDevelopmentRecovery, planDevelopmentRecovery } from './development-support/recovery.js'; import { ownsDevelopmentProcess, processIdentity } from './development-support/process-identity.js'; import { developmentBootOrder } from './development-support/boot-order.js'; +import { managedContainerAlreadyReady, withDevelopmentLifecycle } from './development-support/lifecycle.js'; export { relativeOverlayTarget, startPackageSynchronizer, stopProcess, waitForNewPackageOverlay } from './development-support/overlays.js'; export { developmentCliEntrypointPath, selectDevelopmentCli } from './development-cli-selection.js'; @@ -127,7 +128,7 @@ export function usesManagedContainer(target: DevelopmentTarget) { return target.operations.start?.command === 'docker'; } -async function containerOperation(context: CommandContext, sessionId: string, runtime: DevelopmentRuntime, target: DevelopmentTarget, action: 'start' | 'stop') { +async function containerOperation(context: CommandContext, sessionId: string, runtime: DevelopmentRuntime, target: DevelopmentTarget, action: 'start' | 'stop' | 'status') { return invoke(context, 'local.dev.container', {sessionId,projectId:runtime.project.id,targetId:target.id,action}); } @@ -222,6 +223,10 @@ async function useTargets(invocation: Pick resumeDevelopmentUnlocked(sessionId, context)); +} + +async function resumeDevelopmentUnlocked(sessionId: string, context: CommandContext) { if (!/^dev-[a-z0-9-]{1,64}$/.test(sessionId)) throw new Error('An exact development session is required.'); loadState(context.env, sessionId); const record = await invoke(context, 'local.dev.status', { sessionId, all: false }) as DevelopmentStatusRecord & { session: { status: string } }; @@ -415,6 +424,11 @@ export async function resumeDevelopmentSession(sessionId: string, context: Comma } export async function runDevelopment(invocation: ParsedInvocation, context: CommandContext) { + if (invocation.options.plan === true || ['dev status', 'dev logs', 'dev plan', 'dev host status'].includes(invocation.command.name)) return runDevelopmentUnlocked(invocation, context); + return withDevelopmentLifecycle(context.env, () => runDevelopmentUnlocked(invocation, context)); +} + +async function runDevelopmentUnlocked(invocation: ParsedInvocation, context: CommandContext) { if (invocation.command.name === 'dev session recover') { const sessionId = String(invocation.options.session ?? ''); if (!/^dev-[a-z0-9-]{1,64}$/.test(sessionId)) throw new Error('An exact development session is required.'); diff --git a/tests/support/lifecycle-worker.ts b/tests/support/lifecycle-worker.ts new file mode 100644 index 00000000..a08c75e8 --- /dev/null +++ b/tests/support/lifecycle-worker.ts @@ -0,0 +1,7 @@ +import { withDevelopmentLifecycle } from '../../src/cli/commands/development-support/lifecycle.ts'; + +await withDevelopmentLifecycle({ ...process.env, XDG_STATE_HOME: process.argv[2] }, async () => { + process.send?.('entered'); + await new Promise(resolve => process.once('message', () => resolve())); +}); +process.disconnect?.(); diff --git a/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts b/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts new file mode 100644 index 00000000..0323ec3c --- /dev/null +++ b/tests/unit/command-boundary/development/lifecycle-entrypoints.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { resumeDevelopmentSession, runDevelopment } from '../../../../src/cli/commands/development.ts'; +import type { CommandContext, ParsedInvocation } from '../../../../src/cli/types.ts'; + +test('boot resume and manual use re-read state under the same lifecycle lock', { skip: process.platform !== 'linux' }, async () => { + const root = mkdtempSync(resolve(tmpdir(), 'lifecycle-entry-')); + const sessionId = 'dev-test', env = { ...process.env, XDG_STATE_HOME: root }; + const directory = resolve(root, 'treeseed/development'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync(resolve(directory, 'current.json'), JSON.stringify({ sessionId, manifest: resolve(root, 'session.yaml'), processes: {}, overlays: [], candidates: [] })); + const record = { + session: { sessionId, status: 'active', repositories: [{ projectId: 'api', worktree: root }], + targets: [{ projectId: 'api', targetId: 'operations-runner', mode: 'candidate', generation: 0, health: 'pending' }] }, + runtimes: [{ project: { id: 'api' }, targets: [{ id: 'operations-runner', kind: 'rebuild-restart', + operations: { start: { command: 'docker' } }, dependencies: [], endpoints: [], ready: { kind: 'process', graceSeconds: 0 } }] }], + }; + let started = false, starts = 0; + const context = { + cwd: root, env, + hostInvoke: async (request: { handlerId: string; options: { payload?: unknown } }) => { + const payload = JSON.parse(String(request.options.payload)); + if (request.handlerId === 'local.dev.status' || request.handlerId === 'local.dev.use') return record; + if (request.handlerId === 'local.dev.environment') return { environment: {} }; + if (request.handlerId === 'local.dev.container' && payload.action === 'status') return started + ? { registered: true, state: JSON.stringify({ Name: 'treeseed-dev-test-api-operations-runner', State: 'running', Health: 'healthy' }) } + : { registered: false, state: null }; + if (request.handlerId === 'local.dev.container' && payload.action === 'start') { + starts++; await new Promise(resolve => setTimeout(resolve, 30)); started = true; return { started: true }; + } + throw new Error(`Unexpected operation ${request.handlerId}`); + }, + } as CommandContext; + const invocation = { command: { name: 'dev use' }, arguments: ['api.operations-runner=candidate'], options: { session: sessionId } } as ParsedInvocation; + try { + await Promise.all([resumeDevelopmentSession(sessionId, context), runDevelopment(invocation, context)]); + assert.equal(starts, 1); + await resumeDevelopmentSession(sessionId, context); + assert.equal(starts, 1); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/unit/command-boundary/development/lifecycle.test.ts b/tests/unit/command-boundary/development/lifecycle.test.ts new file mode 100644 index 00000000..a4c5528d --- /dev/null +++ b/tests/unit/command-boundary/development/lifecycle.test.ts @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fork, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { managedContainerAlreadyReady, withDevelopmentLifecycle } from '../../../../src/cli/commands/development-support/lifecycle.ts'; + +const worker = resolve(import.meta.dirname, '../../../support/lifecycle-worker.ts'); +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +function child(root: string) { + return fork(worker, [root], { execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }); +} +async function stop(process: ChildProcess) { + if (process.exitCode !== null || process.signalCode !== null) return; + const exited = once(process, 'exit'); process.kill('SIGKILL'); await exited; +} + +test('Linux manual/resume processes serialize; killing owner releases the lock', { skip: process.platform !== 'linux', timeout: 15000 }, async () => { + const root = mkdtempSync(resolve(tmpdir(), 'lifecycle-')); + const first = child(root); let second: ChildProcess | undefined; + try { + assert.equal((await once(first, 'message'))[0], 'entered'); + second = child(root); + let entered = false; + const ready = once(second, 'message').then(value => { entered = true; return value; }); + await delay(500); assert.equal(entered, false); + await stop(first); + assert.equal((await ready)[0], 'entered'); + const exited = once(second, 'exit'); second.send('release'); + assert.deepEqual(await exited, [0, null]); + await withDevelopmentLifecycle({ XDG_STATE_HOME: root }, async () => {}); + } finally { await stop(first); if (second) await stop(second); rmSync(root, { recursive: true, force: true }); } +}); + +test('lifecycle lock releases after a failed operation', { skip: process.platform !== 'linux' }, async () => { + const root = mkdtempSync(resolve(tmpdir(), 'lifecycle-')); + try { + await assert.rejects(withDevelopmentLifecycle({ XDG_STATE_HOME: root }, async () => { throw new Error('test failure'); }), /test failure/); + assert.equal(await withDevelopmentLifecycle({ XDG_STATE_HOME: root }, async () => 'ready'), 'ready'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('healthy exact managed snapshot is reusable; missing runtime requires startup', () => { + const state = JSON.stringify({ Name: 'treeseed-dev-test-api-operations-runner', State: 'running', Health: 'healthy' }); + assert.equal(managedContainerAlreadyReady({ registered: true, state }, 'dev-test', 'operations-runner'), true); + assert.equal(managedContainerAlreadyReady({ registered: false, state: null }, 'dev-test', 'operations-runner'), false); +}); + +test('registered unhealthy, malformed, or wrong-instance state never authorizes overwrite', () => { + const value = { Name: 'treeseed-dev-test-api-operations-runner', State: 'running', Health: 'unhealthy' }; + assert.throws(() => managedContainerAlreadyReady({ registered: true, state: JSON.stringify(value) }, 'dev-test', 'operations-runner'), /dev restart/); + value.Health = 'healthy'; value.Name = 'unrelated'; + assert.throws(() => managedContainerAlreadyReady({ registered: true, state: JSON.stringify(value) }, 'dev-test', 'operations-runner'), /identity/); + for (const state of ['', 'not json', '{}\n{}']) assert.throws(() => managedContainerAlreadyReady({ registered: true, state }, 'dev-test', 'service')); +}); diff --git a/tests/unit/command-boundary/host/managed-development-container.test.ts b/tests/unit/command-boundary/host/managed-development-container.test.ts index b923eed3..68864e86 100644 --- a/tests/unit/command-boundary/host/managed-development-container.test.ts +++ b/tests/unit/command-boundary/host/managed-development-container.test.ts @@ -22,7 +22,7 @@ test('container startup and cleanup only invoke the protected manager, including if(request.handlerId==='local.dev.session.start'){record={session:payload.session,runtimes:payload.runtimes};records.set(payload.session.sessionId,record);return record;} record=records.get(payload.sessionId)??record; if(request.handlerId==='local.dev.environment')return {environment:{}}; - if(request.handlerId==='local.dev.container'){assert.equal(payload.sessionId,selected);actions.push(payload.action);return {};} + if(request.handlerId==='local.dev.container'){assert.equal(payload.sessionId,selected);actions.push(payload.action);return payload.action==='status'?{registered:false,state:null}:{};} if(request.handlerId==='local.dev.use'){ if(payload.mode!=='released')assert.equal(payload.port,3000,'Every activation, including restart, must reattach the canonical route'); record.session.targets[0].mode=payload.mode; @@ -42,6 +42,6 @@ test('container startup and cleanup only invoke the protected manager, including assert.equal(await runCommandLine(['dev','restart','api.service','--session',selected,'--json'],context),0); assert.equal(await runCommandLine(['dev','use','api.service=released','--session',selected,'--json'],context),0); assert.equal(await runCommandLine(['dev','session','stop','--session',selected,'--json'],context),0); - assert.deepEqual(actions,['start','stop','start','stop','stop']); + assert.deepEqual(actions,['status','start','stop','start','stop','stop']); } finally {rmSync(root,{recursive:true,force:true});} }); From 3705eeb91c78bf5369ca390ae8830f9ac7231196 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Wed, 9 Sep 2026 17:02:46 -0400 Subject: [PATCH 2/2] fix(packaging): reuse bundled OS lock in extracted CLI --- src/cli/commands/development-support/lifecycle.ts | 2 +- src/cli/support/server-custody.ts | 2 ++ tests/contract/package/custody-artifact.test.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/development-support/lifecycle.ts b/src/cli/commands/development-support/lifecycle.ts index 59ad62fb..18cadfa3 100644 --- a/src/cli/commands/development-support/lifecycle.ts +++ b/src/cli/commands/development-support/lifecycle.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import { withOsCustodyLock } from '@treeseed/deployment/security/custody'; +import { withOsCustodyLock } from '../../support/server-custody.js'; import { developmentStateRoot } from '../development-cli-selection.js'; /** Shared builds and current.json cross session boundaries. Lock the Linux diff --git a/src/cli/support/server-custody.ts b/src/cli/support/server-custody.ts index 8d492e3d..c440bd5e 100644 --- a/src/cli/support/server-custody.ts +++ b/src/cli/support/server-custody.ts @@ -2,6 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync import { homedir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { OsSecretCustody, withOsCustodyLock } from '@treeseed/deployment/security/custody'; +// Keep shared OS locking in this already-bundled Deployment custody boundary. +export { withOsCustodyLock }; import { defaultLocalControlPlaneServer, normalizeControlPlaneServerRegistry, type ControlPlaneServerProfile, type ControlPlaneServerRegistry, type ControlPlaneServerSession } from '@treeseed/sdk/control-plane-client'; interface SessionState { version: 1; sessions: ControlPlaneServerSession[]; custodyVersion: number } diff --git a/tests/contract/package/custody-artifact.test.ts b/tests/contract/package/custody-artifact.test.ts index 40862154..0c7b0aac 100644 --- a/tests/contract/package/custody-artifact.test.ts +++ b/tests/contract/package/custody-artifact.test.ts @@ -10,7 +10,7 @@ test('extracted CLI custody imports without a manager or runtime Deployment pack cpSync('dist',join(root,'dist'),{recursive:true});writeFileSync(join(root,'package.json'),'{"type":"module"}'); mkdirSync(join(root,'node_modules','@treeseed'),{recursive:true}); symlinkSync(resolve('node_modules/@treeseed/sdk'),join(root,'node_modules/@treeseed/sdk'),'dir'); - const result=spawnSync(process.execPath,['--input-type=module','-e',"import {inspectServerCustody} from './dist/cli/support/server-custody.js'; const result=inspectServerCustody({TREESEED_CONFIG_HOME:process.cwd()+'/config'}); if(result.custody!=='os'||result.encrypted)process.exit(1);"],{cwd:root,encoding:'utf8',env:{...process.env,NODE_OPTIONS:''}}); + const result=spawnSync(process.execPath,['--input-type=module','-e',"import {inspectServerCustody} from './dist/cli/support/server-custody.js'; import {withDevelopmentLifecycle} from './dist/cli/commands/development-support/lifecycle.js'; await withDevelopmentLifecycle({XDG_STATE_HOME:process.cwd()},async()=>{}); const result=inspectServerCustody({TREESEED_CONFIG_HOME:process.cwd()+'/config'}); if(result.custody!=='os'||result.encrypted)process.exit(1);"],{cwd:root,encoding:'utf8',env:{...process.env,NODE_OPTIONS:''}}); assert.equal(result.status,0,result.stderr); }finally{rmSync(root,{recursive:true,force:true});} });