Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ signals received while the mux session is still being initialized.
If `lifecycle.down.before` fails, shutdown is aborted before Compose or lifecycle processes are
stopped. `hack restart` preserves the same guard semantics during its down phase.

If a concurrent foreground finalizer removes the same owned lifecycle session during shutdown,
Hack accepts a fresh, explicit absence check. Missing ownership metadata by itself, a changed token,
or an unavailable session check still refuses cleanup.

### `hack restart`

`hack restart` runs the down lifecycle hooks and stops owned host processes, but preserves the
Expand Down
15 changes: 13 additions & 2 deletions src/lib/project-lifecycle-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,26 @@ export async function inspectLifecycleSession(opts: {
opts.backend.listSessionWindowNames?.({ name: opts.expectedSessionName }) ??
Promise.resolve(null),
]);
// A concurrent owned finalizer can remove the session after listSessions.
// Missing metadata is not absence: require a fresh exact-session query, and
// never excuse a changed token or state belonging to another session/backend.
const removedOwnedSession =
opts.entry?.backend === opts.backend.name &&
opts.entry.sessionName === opts.expectedSessionName &&
Boolean(opts.entry.ownershipToken) &&
observedOwnershipToken === null &&
(await opts.backend.readSessionPresence?.({
name: opts.expectedSessionName,
})) === "absent";
return classifyLifecycleSession({
session,
session: removedOwnedSession ? null : session,
entry: opts.entry,
observedOwnershipToken,
expectedBackend: opts.backend.name,
expectedSessionName: opts.expectedSessionName,
expectedProjectRoot: await normalizePath(opts.expectedProjectRoot),
expectedDefinitionHash: opts.expectedDefinitionHash,
liveWindowNames,
liveWindowNames: removedOwnedSession ? null : liveWindowNames,
});
}

Expand Down
129 changes: 129 additions & 0 deletions tests/project-lifecycle-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { expect, test } from "bun:test";
import type { LifecycleStateEntry } from "../src/lib/lifecycle-runtime.ts";
import {
classifyLifecycleSession,
inspectLifecycleSession,
killInspectedLifecycleSession,
killLifecycleSessionWithOwnership,
resolveLifecycleDefinitionHash,
resolveLifecycleEnvironmentFingerprint,
Expand Down Expand Up @@ -155,6 +157,133 @@ test("classifyLifecycleSession blocks same-name sessions without ownership proof
expect(inspection.decision).toMatchObject({ kind: "block" });
});

function inspectionBackend(overrides: Partial<MuxBackend> = {}): MuxBackend {
return {
name: "tmux",
available: true,
listSessions: async () => [session],
createSession: async () => ({ ok: true, session }),
killSession: async () => {
throw new Error("Inspection must not kill a session");
},
readLifecycleOwnerToken: async () => null,
listSessionWindowNames: async () => null,
execInSession: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
sendInput: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
...overrides,
};
}

function inspectFixture(backend: MuxBackend) {
return inspectLifecycleSession({
backend,
entry,
expectedSessionName: session.name,
expectedProjectRoot: "/tmp/event-agent",
expectedDefinitionHash: definitionHash,
});
}

test("inspection accepts exact-owned cleanup between session listing and owner read", async () => {
let live = true;
let kills = 0;
const presenceQueries: string[] = [];
const backend = inspectionBackend({
listSessions: async () => {
const snapshot = live ? [session] : [];
// The foreground finalizer retires its session after down took a snapshot.
expect(
await killLifecycleSessionWithOwnership({
backend,
sessionName: session.name,
ownershipToken,
})
).toBe(true);
return snapshot;
},
readLifecycleOwnerToken: async () => (live ? ownershipToken : null),
readSessionPresence: async ({ name }) => {
presenceQueries.push(name);
return live ? "present" : "absent";
},
killSession: async () => {
kills += 1;
live = false;
return { exitCode: 0, stdout: "", stderr: "" };
},
});

const inspection = await inspectFixture(backend);
expect(inspection.classification).toBe("absent");
expect(inspection.decision).toEqual({ kind: "create" });
expect(inspection.session).toBeNull();
expect(presenceQueries).toEqual([session.name]);
expect(await killInspectedLifecycleSession({ backend, inspection })).toBe(
false
);
expect(kills).toBe(1);
});

test.each([
"present",
"unknown",
] as const)("inspection refuses missing ownership when fresh session presence is %s", async (presence) => {
const backend = inspectionBackend({
readSessionPresence: async () => presence,
});
const inspection = await inspectFixture(backend);
expect(inspection.classification).toBe("foreign");
expect(inspection.decision.kind).toBe("block");
});

test("inspection refuses missing ownership without an explicit presence query", async () => {
expect((await inspectFixture(inspectionBackend())).decision.kind).toBe(
"block"
);
});

test("inspection propagates a presence query failure", async () => {
const backend = inspectionBackend({
readSessionPresence: async () => {
throw new Error("Presence query unavailable");
},
});
await expect(inspectFixture(backend)).rejects.toThrow(
"Presence query unavailable"
);
});

test("inspection never excuses a changed token with subsequent absence", async () => {
const backend = inspectionBackend({
readLifecycleOwnerToken: async () => "foreign-token",
readSessionPresence: async () => {
throw new Error(
"A changed token must refuse without an absence fallback"
);
},
});
const inspection = await inspectFixture(backend);
expect(inspection.classification).toBe("foreign");
expect(inspection.decision.kind).toBe("block");
});

test("inspection does not reinterpret unrelated persisted ownership", async () => {
const backend = inspectionBackend({
readSessionPresence: async () => {
throw new Error("Mismatched state must not use an absence fallback");
},
});
const inspection = await inspectLifecycleSession({
backend,
entry: { ...entry, sessionName: "another-session" },
expectedSessionName: session.name,
expectedProjectRoot: "/tmp/event-agent",
expectedDefinitionHash: definitionHash,
});
expect(inspection.classification).toBe("foreign");
expect(inspection.decision.kind).toBe("block");
});

test("killLifecycleSessionWithOwnership cleans up only an exact token match", async () => {
const killed: string[] = [];
let observedToken: string | null = ownershipToken;
Expand Down
Loading