fix(sf): run source server with live web host
This commit is contained in:
parent
f7b262f33a
commit
187d736930
5 changed files with 241 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
113
src/tests/integration/sf-from-source-lock.test.ts
Normal file
113
src/tests/integration/sf-from-source-lock.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
2
web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue