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
37 changes: 25 additions & 12 deletions src/content/holder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function createPersonalspaceHolder(
// before it). `known` holds only decisions made wholly after the last
// forget, so `peek` never shows a stale one either.
let generation = 0;
// The peeks still reading the layout: the decision a peek starts is not
// in `asking` before that (`settled`).
const peeking = new Set<Promise<PersonalspaceHolder>>();

function ask(folder: string, layout: Owner): Promise<PersonalspaceHolder> {
const key = keyOf(layout);
Expand Down Expand Up @@ -143,17 +146,25 @@ export function createPersonalspaceHolder(
* is made. A tile drawn from it cannot be operated into a wrong state:
* every operation decides again. */
async peek(folder: string): Promise<PersonalspaceHolder> {
const layout = await personalspaceLayout(folder);
if (layout.kind === "decided") return layout.holder;
const entry = remembered(folder, layout);
if (
entry === undefined ||
now() - entry.at >= ttl ||
(entry.holder.kind === "blocked" &&
entry.holder.reason === "github-unavailable")
)
void ask(folder, layout);
return entry?.holder ?? unverified;
const peeked = (async () => {
const layout = await personalspaceLayout(folder);
if (layout.kind === "decided") return layout.holder;
const entry = remembered(folder, layout);
if (
entry === undefined ||
now() - entry.at >= ttl ||
(entry.holder.kind === "blocked" &&
entry.holder.reason === "github-unavailable")
)
void ask(folder, layout);
return entry?.holder ?? unverified;
})();
peeking.add(peeked);
try {
return await peeked;
} finally {
peeking.delete(peeked);
}
},
/** gh's account or the content changed (a sign-in or sign-out through
* the Launchpad, a Tools reading that says so, an installation): decide
Expand All @@ -163,8 +174,10 @@ export function createPersonalspaceHolder(
known.clear();
},
/** Resolves when the decisions being made now have ended, stale ones
* included; it answers no decision. */
* included, and those a peek running now starts once it has read the
* layout; it answers no decision. */
async settled(): Promise<void> {
await Promise.allSettled([...peeking]);
await Promise.all([...asking.values()].map((entry) => entry.done));
},
});
Expand Down
84 changes: 61 additions & 23 deletions src/folder/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ const marker = "lazurio-directory-flock-v1\n";
// One persistent inode per explicit owner. Never unlink it: a waiter may still
// reference it, producing two lock domains. Unmarked old locks stay refused.
export async function acquireFolderOperationLock(stateDirectory: string) {
return acquireLock(stateDirectory, undefined);
}

/** `ownRead`, the key of this process's short read, counts the lock as such
* a read from the moment the kernel grants it until it is released, in the
* same synchronous step as the grant and the release: a mutation of this
* process that finds the lock busy then always knows whether its own read
* holds it, also while the read still verifies and syncs the lock (#272's
* wait, issue #413). */
async function acquireLock(
stateDirectory: string,
ownRead: string | undefined,
) {
const cloexec = closeOnExecFlag();
const parent = await inspectOwnedDirectory(stateDirectory);
// Qualification: ext4 by its stable magic number; APFS by name, because the
Expand Down Expand Up @@ -51,6 +64,19 @@ export async function acquireFolderOperationLock(stateDirectory: string) {
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | cloexec,
);
let released = false;
let countedRead = false;
// The descriptor's close releases the kernel lock; only then does this
// process's read stop counting.
const close = async () => {
const counted = countedRead;
countedRead = false;
released = true;
try {
await handle.close();
} finally {
if (counted && ownRead !== undefined) uncountOwnRead(ownRead);
}
};
try {
const opened = await handle.stat();
const assertIdentity = async () => {
Expand All @@ -70,6 +96,10 @@ export async function acquireFolderOperationLock(stateDirectory: string) {
};
await assertIdentity();
lockDirectoryDescriptor(handle.fd);
if (ownRead !== undefined) {
countOwnRead(ownRead);
countedRead = true;
}
if (created) {
// Interrupted initialization stays unrecognized, never adopted on retry.
await assertIdentity();
Expand Down Expand Up @@ -143,24 +173,35 @@ export async function acquireFolderOperationLock(stateDirectory: string) {
try {
await assertHeld();
} finally {
released = true;
await handle.close();
await close();
}
},
});
} catch (error) {
released = true;
await handle.close();
await close();
throw error;
}
}

/** This process's short reads that hold the lock now, by state directory.
* A mutation of this process waits a bounded moment for them (issue #272):
* the shell setup cache starts its background read right after a profile
* change, and the person's next change must not fail as busy because of the
* Launchpad's own read. It never waits for another process. */
const ownReads = new Map<string, number>();
/** This process's short reads that hold the lock now, by state directory,
* and how many of them have ended. A mutation of this process waits a
* bounded moment for them (issue #272): the shell setup cache starts its
* background read right after a profile change, and the person's next change
* must not fail as busy because of the Launchpad's own read. It never waits
* for another process. A read counts from the kernel's grant to its release
* (`acquireLock`); one that ended while a refused mutation was on its way is
* waited for too, because it may have been what refused it. */
const ownReads = new Map<string, Readonly<{ held: number; ended: number }>>();

function countOwnRead(key: string) {
const reads = ownReads.get(key) ?? { held: 0, ended: 0 };
ownReads.set(key, { held: reads.held + 1, ended: reads.ended });
}

function uncountOwnRead(key: string) {
const reads = ownReads.get(key) ?? { held: 1, ended: 0 };
ownReads.set(key, { held: reads.held - 1, ended: reads.ended + 1 });
}

/** How long a mutation of this process waits for its own short reads. */
export const ownReadWaitMs = 3_000;
Expand All @@ -173,15 +214,20 @@ export async function withFolderOperationLock<T>(
operation: (assertHeld: () => Promise<void>) => Promise<T>,
waitForOwnReadsMs = ownReadWaitMs,
): Promise<T> {
const key = resolve(stateDirectory);
const deadline = Date.now() + waitForOwnReadsMs;
for (let attempt = 1; ; attempt += 1) {
const endedBefore = ownReads.get(key)?.ended ?? 0;
let lock: Awaited<ReturnType<typeof acquireFolderOperationLock>>;
try {
lock = await acquireFolderOperationLock(stateDirectory);
} catch (error) {
const reads = ownReads.get(key);
const ownRead =
reads !== undefined && (reads.held > 0 || reads.ended !== endedBefore);
if (
!(error instanceof FolderOperationBusyError) ||
(ownReads.get(resolve(stateDirectory)) ?? 0) === 0 ||
!ownRead ||
Date.now() >= deadline
)
throw error;
Expand All @@ -196,26 +242,18 @@ export async function withFolderOperationLock<T>(
}
}

/** Holds the lock for one short read, counted as this process's own until
* the lock is released: a mutation that finds the lock busy meanwhile knows
/** Holds the lock for one short read, counted as this process's own while
* the kernel grants it: a mutation that finds the lock busy meanwhile knows
* whom it waits for. */
async function ownRead<T>(
stateDirectory: string,
operation: (assertHeld: () => Promise<void>) => Promise<T>,
): Promise<T> {
const lock = await acquireFolderOperationLock(stateDirectory);
const key = resolve(stateDirectory);
ownReads.set(key, (ownReads.get(key) ?? 0) + 1);
const lock = await acquireLock(stateDirectory, resolve(stateDirectory));
try {
return await operation(lock.assertHeld);
} finally {
try {
await lock.release();
} finally {
const left = (ownReads.get(key) ?? 1) - 1;
if (left > 0) ownReads.set(key, left);
else ownReads.delete(key);
}
await lock.release();
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/launchpad/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,15 @@ export async function startLaunchpad(
? `${server.url.href}#${token}`
: `${entry.externalOrigin}/`,
hosted: entry !== null,
/** Resolves when the reads this Launchpad began on its own in the
* background have ended: whose Personalspace this is, decided at its
* start, and the shell's setup, read again after a change. No answer
* waits for them (none waits for GitHub); what observes what the
* Launchpad asks GitHub or its Folder waits here first, so that it
* observes the request it makes and not the start's own work. */
async settled(): Promise<void> {
await Promise.all([personalspace.settled(), setup.settled()]);
},
close() {
closing = true;
clearInterval(audienceTimer);
Expand Down
6 changes: 6 additions & 0 deletions tests/content-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ async function launchpad(
Authorization: `Bearer ${url.hash.slice(1)}`,
};
return {
settled: () => app.settled(),
get: (path: string) => fetch(new URL(path, url), { headers }),
post: (path: string, body: unknown, extra: Record<string, string> = {}) =>
fetch(new URL(path, url), {
Expand Down Expand Up @@ -992,7 +993,10 @@ posixTest(
// An active Owner (the stub's default membership).
const stub = stubGitHub(world);
const app = await launchpad(folder, contentHost(world, stub.github));
// What a request asks is what it adds once the start's own background
// reads (whose Personalspace this is) have ended (issue #413).
const access = async (org: string) => {
await app.settled();
const before = stub.calls.length;
const answer = await app.get(`/api/content/root-access?org=${org}`);
expect(answer.status).toBe(200);
Expand All @@ -1011,6 +1015,7 @@ posixTest(
closing = undefined;
const stopped = await unprepared(await presetFolder(world, "local"), stub);
const ask = async (org: string) => {
await stopped.settled();
const before = stub.calls.length;
const answer = await stopped.get(`/api/content/root-access?org=${org}`);
expect(answer.status).toBe(200);
Expand Down Expand Up @@ -1095,6 +1100,7 @@ posixTest(
failure?: { code: string };
};
expect(ended.failure?.code).toBe("github-signed-out");
await app.settled();
const before = signedOut.calls.length;
const answer = await app.get("/api/content/root-access?org=Alpha");
expect(await answer.json()).toEqual({ kind: "root-access", write: false });
Expand Down
5 changes: 2 additions & 3 deletions tests/environment-browser-view-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
withBrowser,
} from "./fixtures/machine-bindings";
import organization from "./fixtures/machine-context.json";
import { freePort } from "./fixtures/ports";

// F39's addendum of 2026-10-12: unframed (a bare tab, the panel's pop-out, a
// phone), the Environment browser's view names its Environment by the
Expand Down Expand Up @@ -98,9 +99,7 @@ posixTest(
await mkdir(folder, { mode: 0o700 });
await mkdir(join(folder, "organizations"), { mode: 0o755 });
await mkdir(join(folder, "personalspace"), { mode: 0o700 });
const probe = Bun.serve({ port: 0, fetch: () => new Response("") });
const port = probe.port ?? 0;
probe.stop(true);
const port = freePort();
const preset = "hosted-organization-personal";
const host = "workspace.example.lazurio.io";
await initializeHandoverFolder(folder, {
Expand Down
13 changes: 1 addition & 12 deletions tests/environment-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
withBrowser,
} from "./fixtures/machine-bindings";
import organizationContext from "./fixtures/machine-context.json";
import { freePort } from "./fixtures/ports";
import { commitOf, target } from "./fixtures/update-world";

// The Environment browser (root decision 0191, decisions F38 and F39): its
Expand Down Expand Up @@ -1853,18 +1854,6 @@ test("lazurio browser exits as the rest of the CLI does", () => {

// ---- The Launchpad's routes --------------------------------------------------

const freePort = () => {
const probe = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response(""),
});
const port = probe.port;
probe.stop(true);
if (port === undefined) throw new Error("The probe listens on a port");
return port;
};

async function hostedLaunchpad(withView: boolean) {
const parent = await temporary("launchpad-browser-");
const folder = join(parent, "Lazurio");
Expand Down
12 changes: 1 addition & 11 deletions tests/first-run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type { ToolOverview } from "../src/tools/overview";
import type { ToolRunner } from "../src/tools/status";
import { writeOrganization } from "./fixtures/catalog-folder";
import { organizationWithEntry } from "./fixtures/machine-bindings";
import { freePort } from "./fixtures/ports";

// The first run (root decisions 0188 and 0201) beyond the page: the shell
// document's optional `setup` with the Environment's AI, the column head's
Expand Down Expand Up @@ -398,17 +399,6 @@ test("prepare-content: a Folder prompt for the Environment's operator, opened in
expect(document.text).toContain("`lazurio personalspace install`");
});

const freePort = () => {
const probe = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response(""),
});
const port = probe.port ?? 0;
probe.stop(true);
return port;
};

// A hosted work Environment whose gh is signed out. With `content`, what
// lives here and the last installation are stubbed; without it, the
// Launchpad's own content routes answer.
Expand Down
46 changes: 46 additions & 0 deletions tests/fixtures/ports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { randomInt } from "node:crypto";

// A loopback port for a listener a test configures before it starts (a
// handover's `listenPort`, a gateway's), when the listener cannot take port 0
// itself (issue #413).
//
// Probing port 0 and releasing it hands out an ephemeral port: until the
// listener binds it, the kernel may give the same port to any other socket,
// another worker's outgoing connection or port-0 listener among them, and
// the start fails with "Failed to start server. Is port <n> in use?". These
// ports lie below both kernels' ephemeral ranges (Linux 32768-60999, macOS
// 49152-65535), so the kernel never hands them out on its own. Each worker of
// `bun test --parallel` takes them from a range of its own, by
// BUN_TEST_WORKER_ID (a run without workers takes all of them); a file never
// gets the same port twice; and a port that something listens on already is
// skipped. Two test runs on one machine at the same time may still meet.
const first = 20_000;
const slice = 1_000;
const slices = 12;

const given = new Set<number>();

export function freePort(): number {
const worker = Number(process.env.BUN_TEST_WORKER_ID);
const [base, size] =
Number.isInteger(worker) && worker > 0
? [first + ((worker - 1) % slices) * slice, slice]
: [first, slices * slice];
const start = randomInt(size);
for (let step = 0; step < size; step += 1) {
const port = base + ((start + step) % size);
if (given.has(port)) continue;
try {
Bun.serve({
hostname: "127.0.0.1",
port,
fetch: () => new Response(""),
}).stop(true);
} catch {
continue;
}
given.add(port);
return port;
}
throw new Error("No free loopback port left for this test file");
}
Loading
Loading