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
42 changes: 42 additions & 0 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,48 @@ graph history, remove data or establish browser readiness. Doctor's ordinary
`--fix` never applies it. Run the normal project command separately after reviewing
the result; its existing native graph and ownership checks still apply.

#### A run mapping from another native home

Each launcher version has its own native home, but the run mapping lives in the
project. After an upgrade, a mapping the previous version left behind names that
version's home. Project commands from the new home (`ps`, `up`, `down`,
`doctor --native-cleanup inspect`) refuse it with a line that names the owning home
and what to do:

- **Stopped cleanly.** The owning home's frontend records show that the run's latest
`hack up` acknowledged confirmed graph cleanup, HTTPS retirement and down hooks.
The refusal names the exact release command. Inspection reports
`state: "foreign-home"`, `owner: "stopped"` and the `selectionSha256` it needs:

```sh
./hack-v5 doctor --path /absolute/project --native-run-mapping inspect --json
./hack-v5 doctor --path /absolute/project --native-run-mapping release --expect-selection <selectionSha256> --json
```

- **Live.** The owning frontend's PID still exists. The refusal names the home and
PID. Stop it with Ctrl-C in that `hack up`, or run `hack down` with the version
that owns that home, then retry. Release refuses.
- **Interrupted or unproven.** The frontend exited without acknowledging cleanup, or
the home has no readable record for the run (for example a legacy mapping, or a
home that was moved or replaced). Use the owning version's
`hack doctor --native-cleanup inspect` and `hack down`. Release refuses.

Release proves ownership; it never just overwrites. The stored scope must differ
from this checkout in its native home only, that home must still be the recorded
directory, and the release holds the owning home's finalization lock, so that home
cannot start the run again meanwhile. A retained `hack up` in the owning home records
an admission under that same lock before its last mapping check and before any
runtime effect, and keeps it until it registers its own frontend lifetime. Release
refuses while an admission exists (a live startup is reported as live; one whose
startup exited is reported as interrupted). Owning homes from versions without this
admission record are not covered by it. A changed selection, held project locks or a
pending restart refuse, as does a down-hook journal that is owed, unknown or another
run's. A completed record of the same run, which a successful stop keeps, is allowed.
Release removes only the project's mapping and keeps its exact bytes in an audit
file beside it. The previous home's
graph and named volumes stay in that home; this home's next `hack up` starts the
instance with fresh data.

Compatibility is qualified for specific bundle hashes and state formats. The
displayed version alone does not establish frontend/executor or downgrade
compatibility. V4 and candidate homes remain separate; this procedure does not
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1431,7 +1431,7 @@ hack doctor [options]
| `--browser-result unknown|works|fails|permission-denied` | Your manual browser observation for --browser-url (default: unknown) |
| `--branch <name>` | Run against a branch-specific instance (compose name + hostnames) |
| `--native-cleanup inspect` | Inspect one mapped native graph's pending cleanup without recovery or stop replay |
| `--native-run-mapping inspect|repair` | Inspect a native run mapping, or explicitly repair it after filesystem device renumbering |
| `--native-run-mapping inspect|repair|release` | Inspect a native run mapping, explicitly repair it after filesystem device renumbering, or release one another native home left after a clean stop |
| `--expect-selection <64-hex>` | Require the exact run-mapping recovery inspection selection |
| `--accept-legacy-device-rebind` | Explicitly accept legacy migration without proof of original filesystem volume continuity |
| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
Expand Down
229 changes: 216 additions & 13 deletions src/backends/native-project-finalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ async function selected(
async function read(
path: string
): Promise<NativeProjectFinalizationToken | null> {
const value = await readRecord(path);
if (value === null) {
return null;
}
if (!isNativeProjectFinalizationToken(value)) {
throw refused();
}
return value;
}
/** One private, singly linked, bounded JSON record; absence is null. */
async function readRecord(path: string): Promise<unknown> {
let file: Awaited<ReturnType<typeof open>>;
try {
file = await open(
Expand Down Expand Up @@ -259,22 +270,12 @@ async function read(
) {
throw refused();
}
const value: unknown = JSON.parse(
bytes.subarray(0, bytesRead).toString("utf8")
);
if (!isNativeProjectFinalizationToken(value)) {
throw refused();
}
return value;
return JSON.parse(bytes.subarray(0, bytesRead).toString("utf8"));
} finally {
await file.close();
}
}
async function write(
root: string,
name: string,
token: NativeProjectFinalizationToken
) {
async function write(root: string, name: string, token: unknown) {
const temporary = join(root, `${randomBytes(16).toString("hex")}.tmp`);
const file = await open(
temporary,
Expand Down Expand Up @@ -312,7 +313,7 @@ async function locked<T>(root: string, fn: () => Promise<T>): Promise<T> {
}
}
function matches(
token: NativeProjectFinalizationToken,
token: FinalizationIdentity,
hash: string,
run: NativeProjectRun
): boolean {
Expand Down Expand Up @@ -530,3 +531,205 @@ export async function waitNativeProjectFinalization(opts: {
await Bun.sleep(Math.min(100, Math.max(1, end - performance.now())));
}
}

/**
* What a native home's own frontend records say about one run's latest lifetime,
* read without changing anything:
* - `completed`: that lifetime acknowledged confirmed graph cleanup, HTTPS
* retirement and down hooks (a clean stop). Nothing else proves it.
* - `live`: its frontend PID still exists (or cannot be signalled), so it may own
* resources. PID reuse can only make an exited owner look live.
* - `interrupted`: its frontend exited without acknowledging cleanup.
* - `unproven`: no readable lifetime, a legacy token without a PID, or an
* explicitly recovered dead owner whose cleanup this record does not attest.
*/
export type NativeProjectFinalizationLifetime =
| { readonly state: "completed"; readonly attempt: string }
| { readonly state: "live"; readonly pid: number }
| { readonly state: "interrupted"; readonly pid: number }
| { readonly state: "unproven" };

function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return !(isRecord(error) && error.code === "ESRCH");
}
}

/**
* A retained startup's admission: present from just before its last run-mapping check
* (so before any runtime effect) until it registers its own frontend lifetime. While
* it exists, the run's latest lifetime record is not the whole truth.
*/
type FinalizationAdmission = FinalizationIdentity & {
readonly version: 1;
readonly pid: number;
};
const ADMISSION = "admission.json";
function isAdmission(value: unknown): value is FinalizationAdmission {
return (
isRecord(value) &&
Object.keys(value).sort().join() ===
"attempt,namespace,owner,pid,planId,run,scope,version" &&
value.version === 1 &&
validPid(value.pid) &&
[value.attempt, value.run, value.owner].every(
(v) => typeof v === "string" && HEX32.test(v)
) &&
[value.scope, value.namespace, value.planId].every(
(v) => typeof v === "string" && HEX64.test(v)
)
);
}
async function readAdmission(
root: string
): Promise<FinalizationAdmission | null> {
const value = await readRecord(join(root, ADMISSION));
if (value !== null && !isAdmission(value)) {
throw refused();
}
return value;
}

/**
* Admit a retained startup of `run` in this native home before its last mapping
* check and any runtime effect. Under the run's finalization lock, so a release of
* the mapping from another home either completed first (the caller's mapping check
* then refuses) or sees this admission and refuses. Another live admission refuses.
* `retire` removes only this admission: after the new lifetime is registered, or
* when startup ends before any runtime effect. Otherwise it stays as evidence of an
* interrupted startup until a later admission of this home replaces it.
*/
export async function admitNativeProjectFinalization(opts: {
readonly scope: NativeProjectRunScope;
readonly run: NativeProjectRun;
}): Promise<{ readonly retire: () => Promise<void> }> {
const { root, hash } = await selected(opts.scope, opts.run, true);
const admission: FinalizationAdmission = {
version: 1,
attempt: randomBytes(16).toString("hex"),
scope: hash,
run: opts.run.run,
owner: opts.run.owner,
namespace: opts.run.namespace,
planId: opts.run.planId,
pid: process.pid,
};
await locked(root, async () => {
const prior = await readAdmission(root);
if (prior && prior.pid !== process.pid && processExists(prior.pid)) {
throw refused();
}
await write(root, ADMISSION, admission);
});
let retired = false;
return {
retire: async () => {
if (retired) {
return;
}
await locked(root, async () => {
const current = await readAdmission(root);
if (current?.attempt === admission.attempt) {
await unlink(join(root, ADMISSION));
const dir = await open(
root,
constants.O_RDONLY | constants.O_NOFOLLOW
);
try {
await dir.sync();
} finally {
await dir.close();
}
}
});
retired = true;
},
};
}

async function lifetimeIn(
root: string,
hash: string,
run: NativeProjectRun,
alive: (pid: number) => boolean
): Promise<NativeProjectFinalizationLifetime> {
const admission = await readAdmission(root);
if (admission) {
// A startup is (or was, until interrupted) restoring this run; its records
// below may still describe the previous lifetime.
if (!matches(admission, hash, run)) {
return { state: "unproven" };
}
return alive(admission.pid)
? { state: "live", pid: admission.pid }
: { state: "interrupted", pid: admission.pid };
}
const active = await read(join(root, "active.json"));
if (!(active && matches(active, hash, run))) {
return { state: "unproven" };
}
const completed = await read(join(root, "completed.json"));
if (completed && same(active, completed)) {
return { state: "completed", attempt: active.attempt };
}
if (active.version === 1) {
return { state: "unproven" };
}
if (alive(active.pid)) {
return { state: "live", pid: active.pid };
}
const recovered = await read(join(root, "recovered.json"));
return recovered && same(active, recovered)
? { state: "unproven" }
: { state: "interrupted", pid: active.pid };
}

/**
* Classify the latest frontend lifetime of `run` under `scope.nativeHome`, which may
* be another native home than the caller's own. Unreadable or unsafe records are
* `unproven`, never an error, so callers can always name the owning home.
*/
export async function inspectNativeProjectFinalizationLifetime(opts: {
readonly scope: NativeProjectRunScope;
readonly run: NativeProjectRun;
readonly isAlive?: (pid: number) => boolean;
}): Promise<NativeProjectFinalizationLifetime> {
try {
const { root, hash } = await selected(opts.scope, opts.run, false);
return await lifetimeIn(
root,
hash,
opts.run,
opts.isAlive ?? processExists
);
} catch {
return { state: "unproven" };
}
}

/**
* Run `action` while holding the home's finalization operation lock for `run`, and
* only while its latest lifetime is still the exact completed `attempt`. A frontend
* of that home cannot begin, complete or recover a lifetime for this run meanwhile.
*/
export async function withCompletedNativeProjectFinalization<T>(opts: {
readonly scope: NativeProjectRunScope;
readonly run: NativeProjectRun;
readonly attempt: string;
readonly action: () => Promise<T>;
}): Promise<T> {
if (!HEX32.test(opts.attempt)) {
throw refused();
}
const { root, hash } = await selected(opts.scope, opts.run, false);
return await locked(root, async () => {
const lifetime = await lifetimeIn(root, hash, opts.run, processExists);
if (lifetime.state !== "completed" || lifetime.attempt !== opts.attempt) {
throw refused();
}
return await opts.action();
});
}
Loading
Loading