fix(sf): run source server with live web host

This commit is contained in:
Mikael Hugo 2026-05-17 19:13:10 +02:00
parent f7b262f33a
commit 187d736930
5 changed files with 241 additions and 17 deletions

View file

@ -58,6 +58,39 @@ if [[ "${1:-}" == "headless" ]]; then
echo "[forge] Preparing source runtime for headless command..."
fi
SF_PROJECT_LOCK_DIR="$(pwd)/.sf"
SF_PROJECT_LOCK_FILE="$SF_PROJECT_LOCK_DIR/sf.lock"
sf_lock_holder_pid() {
local lock_file="$1"
local holder
holder=$(cat "$lock_file" 2>/dev/null || true)
[[ "$holder" =~ (^|[[:space:]])pid=([0-9]+) ]] || return 1
printf '%s\n' "${BASH_REMATCH[2]}"
}
sf_cleanup_dead_lock_holder() {
local lock_file="$1"
[[ -f "$lock_file" ]] || return 0
local holder_pid
holder_pid=$(sf_lock_holder_pid "$lock_file" || true)
[[ -n "$holder_pid" ]] || return 0
if kill -0 "$holder_pid" 2>/dev/null; then
return 0
fi
rm -f -- "$lock_file" 2>/dev/null || true
echo "[forge] Removed stale SF project lock for dead pid $holder_pid: $lock_file" >&2
}
sf_cleanup_own_lock_holder() {
local holder_pid
holder_pid=$(sf_lock_holder_pid "$SF_PROJECT_LOCK_FILE" || true)
[[ "$holder_pid" == "$$" ]] || return 0
rm -f -- "$SF_PROJECT_LOCK_FILE" 2>/dev/null || true
}
sf_cleanup_dead_lock_holder "$SF_PROJECT_LOCK_FILE"
# Single-writer project lock. Two SF processes writing to the same
# .sf/sf.db over WAL cause torn pages and "database disk image is
# malformed" corruption (observed 2026-05-17 in dogfood-5 — see
@ -95,9 +128,7 @@ esac
case "${__SF_NEEDS_LOCK:-}" in
1)
if [[ -z "${SF_SKIP_LOCK:-}" ]]; then
SF_PROJECT_LOCK_DIR="$(pwd)/.sf"
mkdir -p "$SF_PROJECT_LOCK_DIR" 2>/dev/null || true
SF_PROJECT_LOCK_FILE="$SF_PROJECT_LOCK_DIR/sf.lock"
# Open read+write WITHOUT truncating so collision branch can still
# read the current holder before failing. Truncate happens AFTER
# flock succeeds (below) so two racers don't clobber each other's
@ -115,6 +146,7 @@ case "${__SF_NEEDS_LOCK:-}" in
# Truncate + write our holder metadata AFTER acquiring the lock.
: > "$SF_PROJECT_LOCK_FILE"
echo "pid=$$ args=$* cwd=$(pwd) started=$(date -Iseconds)" >&200
trap sf_cleanup_own_lock_holder EXIT
fi
;;
esac

View file

@ -0,0 +1,113 @@
/**
* sf-from-source-lock.test.ts source wrapper lock lifecycle contracts.
*
* Purpose: prove the human source-mode CLI does not leave stale `.sf/sf.lock`
* metadata that blocks later SF diagnostics or write commands.
*/
import assert from "node:assert/strict";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "vitest";
const projectRoot = process.cwd();
const wrapperPath = join(projectRoot, "bin", "sf-from-source");
const deadPid = "2147483647";
function makeFakeNode(dir: string): string {
const fakeNode = join(dir, "fake-node");
writeFileSync(
fakeNode,
[
"#!/usr/bin/env bash",
'printf \'%s\\n\' "$*" >> "$SF_FAKE_NODE_LOG"',
"exit 0",
"",
].join("\n"),
"utf8",
);
chmodSync(fakeNode, 0o755);
return fakeNode;
}
function runWrapper(
cwd: string,
args: string[],
fakeNode: string,
logPath: string,
) {
return spawnSync("bash", [wrapperPath, ...args], {
cwd,
encoding: "utf8",
env: {
...process.env,
SF_NODE_BIN: fakeNode,
SF_FAKE_NODE_LOG: logPath,
},
});
}
test("sfFromSource_when_write_command_exits_removes_own_project_lock", () => {
const tmp = mkdtempSync(join(tmpdir(), "sf-source-lock-cleanup-"));
try {
const fakeNode = makeFakeNode(tmp);
const logPath = join(tmp, "fake-node.log");
const result = runWrapper(
tmp,
["headless", "feedback", "add", "--summary", "test"],
fakeNode,
logPath,
);
assert.equal(result.status, 0, result.stderr);
assert.equal(
existsSync(join(tmp, ".sf", "sf.lock")),
false,
"source wrapper must remove its own lock metadata on clean exit",
);
assert.match(
readFileSync(logPath, "utf8"),
/src\/loader\.ts headless feedback add/,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("sfFromSource_when_readOnlyCommandSeesDeadPidLock_removesStaleMetadata", () => {
const tmp = mkdtempSync(join(tmpdir(), "sf-source-lock-stale-"));
try {
const fakeNode = makeFakeNode(tmp);
const logPath = join(tmp, "fake-node.log");
const sfDir = join(tmp, ".sf");
mkdirSync(sfDir, { recursive: true });
const lockPath = join(sfDir, "sf.lock");
writeFileSync(
lockPath,
`pid=${deadPid} args=headless feedback add cwd=${resolve(tmp)} started=2026-05-17T00:00:00+00:00\n`,
"utf8",
);
const result = runWrapper(tmp, ["headless", "query"], fakeNode, logPath);
assert.equal(result.status, 0, result.stderr);
assert.equal(
existsSync(lockPath),
false,
"read-only diagnostics must clear dead holder metadata before startup",
);
assert.match(result.stderr, /Removed stale SF project lock/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});

View file

@ -126,7 +126,7 @@ test("cli.ts branches to server mode before interactive startup and preserves cw
});
});
test("launchWebMode prefers the packaged standalone host and opens the resolved URL", async (_t) => {
test("launchWebMode uses packaged standalone host when no source web host exists", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-web-host-"));
const standaloneRoot = join(tmp, "dist", "web", "standalone");
const serverPath = join(standaloneRoot, "server.js");
@ -239,6 +239,76 @@ test("launchWebMode prefers the packaged standalone host and opens the resolved
assert.equal(webMode.readPidFile(pidFilePath), 99999);
});
test("launchWebMode prefers source web host over stale standalone output", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-web-source-preferred-"));
const standaloneRoot = join(tmp, "dist", "web", "standalone");
const serverPath = join(standaloneRoot, "server.js");
const sourceWebRoot = join(tmp, "web");
const sourceManifest = join(sourceWebRoot, "package.json");
mkdirSync(standaloneRoot, { recursive: true });
mkdirSync(sourceWebRoot, { recursive: true });
writeFileSync(serverPath, 'console.log("stale standalone")\n');
writeFileSync(sourceManifest, '{"scripts":{"dev":"next dev"}}\n');
let spawnInvocation:
| { command: string; args: readonly string[]; options: Record<string, any> }
| undefined;
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
const status = await webMode.launchWebMode(
{
cwd: "/tmp/current-project",
projectSessionsDir: "/tmp/.sf/sessions/--tmp-current-project--",
agentDir: "/tmp/.sf/agent",
packageRoot: tmp,
port: 45124,
},
{
initResources: () => undefined,
env: {},
spawn: (command, args, options) => {
spawnInvocation = {
command,
args,
options: options as Record<string, any>,
};
return {
pid: 99998,
once: () => undefined,
unref: () => undefined,
} as any;
},
waitForBootReady: async () => undefined,
openBrowser: () => undefined,
writePidFile: () => undefined,
registryPath: join(tmp, "web-instances.json"),
},
);
assert.equal(status.ok, true);
if (!status.ok) throw new Error("expected successful web launch status");
assert.equal(status.hostKind, "source-dev");
assert.equal(status.hostPath, sourceManifest);
assert.equal(status.hostRoot, sourceWebRoot);
assert.equal(spawnInvocation?.command, "npm");
assert.deepEqual(spawnInvocation?.args, [
"run",
"dev",
"--",
"--webpack",
"--hostname",
"127.0.0.1",
"--port",
"45124",
]);
assert.equal(spawnInvocation?.options.cwd, sourceWebRoot);
assert.equal(spawnInvocation?.options.env.SF_WEB_HOST_KIND, "source-dev");
assert.equal(spawnInvocation?.options.env.NEXT_PUBLIC_SF_DEV, "1");
});
test("launchWebMode defaults to fixed port 4000 when no port is specified", async (_t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-web-fixed-port-"));
const standaloneRoot = join(tmp, "dist", "web", "standalone");

View file

@ -510,6 +510,18 @@ export function resolveWebHostBootstrap(
): WebHostBootstrap {
const packageRoot = options.packageRoot ?? DEFAULT_PACKAGE_ROOT;
const checkExists = options.existsSync ?? existsSync;
const sourceWebRoot = join(packageRoot, "web");
const sourceManifest = join(sourceWebRoot, "package.json");
if (checkExists(sourceManifest)) {
return {
ok: true,
kind: "source-dev",
packageRoot,
hostRoot: sourceWebRoot,
entryPath: sourceManifest,
};
}
const packagedStandaloneServer = join(
packageRoot,
"dist",
@ -527,18 +539,6 @@ export function resolveWebHostBootstrap(
};
}
const sourceWebRoot = join(packageRoot, "web");
const sourceManifest = join(sourceWebRoot, "package.json");
if (checkExists(sourceManifest)) {
return {
ok: true,
kind: "source-dev",
packageRoot,
hostRoot: sourceWebRoot,
entryPath: sourceManifest,
};
}
return {
ok: false,
packageRoot,
@ -614,7 +614,16 @@ function buildSpawnSpec(
return {
command: getSpawnCommandForSourceHost(platform),
args: ["run", "dev", "--", "--hostname", host, "--port", String(port)],
args: [
"run",
"dev",
"--",
"--webpack",
"--hostname",
host,
"--port",
String(port),
],
cwd: resolution.hostRoot,
};
}

2
web/next-env.d.ts vendored
View file

@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.