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
51 changes: 41 additions & 10 deletions packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ const spawnGroupLeader = (args: ReadonlyArray<string>, script: string) =>
if (pid === undefined) return yield* Effect.die("spawn failed");
const grouped = yield* Deferred.make<true | string>();
const exited = yield* Deferred.make<NodeJS.Signals | null>();
const printed = yield* Deferred.make<void>();
child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal)));
// "close" fires after stdout drains, so a guard line always lands first. It only fires before
// "grouped" when the shell died without starting the script, such as from a signal.
Expand All @@ -77,12 +76,9 @@ const spawnGroupLeader = (args: ReadonlyArray<string>, script: string) =>
Effect.succeed(`\`ps -o pgid= -p ${pid}\` printed ${JSON.stringify(output)}`),
);
}
if (line === "ready") Deferred.doneUnsafe(printed, Effect.void);
});
const result = yield* Deferred.await(grouped);
return result === true
? { pid, exited: Deferred.await(exited), printed: Deferred.await(printed) }
: result;
return result === true ? { pid, exited: Deferred.await(exited) } : result;
});

// A pid colliding with a live group id is rare, so a few retries clear it. Repeated failures
Expand Down Expand Up @@ -152,18 +148,21 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
// The inner shell prints once it has exec'd. Until then it still carries the wrapper's
// command line, and recording that would make the reaper skip the group as recycled.
const gate = path.join(stateDir, "gate");
const ready = path.join(stateDir, "ready");
NodeChildProcess.execFileSync("mkfifo", [gate, ready]);
// The wrapper's child is recorded before it execs and stopped after.
const wrapper = yield* spawnGroup(
SERVE_ARGS,
'/bin/sh -c "echo ready; sleep 600 & wait" "$0" "$@" & exit 0',
`(read _ < '${gate}'; exec /bin/sh -c 'echo > "${ready}"; sleep 600 & wait' "$0" "$@") & exit 0`,
);
yield* wrapper.exited;
yield* wrapper.printed;
expect(groupExists(wrapper.pid)).toBe(true);

yield* recordFromDeadServer(stateDir, wrapper);
expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
yield* fs.writeFileString(gate, "\n");
yield* fs.readFileString(ready);

const restarted = yield* OpenCodeServerLedger.make({ stateDir });
yield* restarted.reapOrphans;
Expand All @@ -173,6 +172,38 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo
}).pipe(provideHost),
);

it.live("stops a group recorded by an older server without serve arguments", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
const orphan = yield* spawnGroup(SERVE_ARGS);
yield* recordFromDeadServer(stateDir, orphan);
const entryPath = path.join(stateDir, "opencode-servers", `${orphan.pid}.json`);
const { args: _args, ...legacy } = JSON.parse(yield* fs.readFileString(entryPath));
yield* fs.writeFileString(entryPath, JSON.stringify(legacy));

const restarted = yield* OpenCodeServerLedger.make({ stateDir });
yield* restarted.reapOrphans;

expect(groupExists(orphan.pid)).toBe(false);
}).pipe(provideHost),
);

it.live("stops a group whose wrapper appends its own options", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
const orphan = yield* spawnGroup([...SERVE_ARGS, "--log-level=ERROR"]);
yield* recordFromDeadServer(stateDir, orphan);

const restarted = yield* OpenCodeServerLedger.make({ stateDir });
yield* restarted.reapOrphans;

expect(groupExists(orphan.pid)).toBe(false);
}).pipe(provideHost),
);

it.live("leaves a recycled pid alone when its start time does not match", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -201,7 +232,7 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo
yield* recordFromDeadServer(stateDir, unrelated);
const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
const entry = yield* fs.readFileString(entryPath);
yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097"));
yield* fs.writeFileString(entryPath, entry.replaceAll("--port=4096", "--port=4097"));

const restarted = yield* OpenCodeServerLedger.make({ stateDir });
yield* restarted.reapOrphans;
Expand Down
17 changes: 12 additions & 5 deletions packages/provider-opencode/src/server/OpenCodeServerLedger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const OpenCodeServerEntry = Schema.Struct({
pid: Schema.Int,
startTime: Schema.String,
command: Schema.String,
args: Schema.optionalKey(Schema.Array(Schema.String)),
port: Schema.Int,
stateDir: Schema.String,
owner: ProcessIdentity,
Expand Down Expand Up @@ -73,6 +74,11 @@ const parseDarwinPs = (output: string): ReadonlyArray<ObservedProcess> =>
];
});

// A wrapper's forked child keeps the wrapper's command line until it execs; the
// serve arguments survive the exec, so they identify the server either way.
const runsServe = (observed: ObservedProcess, args: ReadonlyArray<string>) =>
observed.command.endsWith(` ${args.join(" ")}`);

const signalGroup = (pgid: number, signal: NodeJS.Signals) => {
try {
signalProcessGroup(pgid, signal);
Expand Down Expand Up @@ -193,9 +199,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
leader !== undefined && !leader.zombie && leader.pgid === pgid
? [leader]
: (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie);
const member =
members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ??
members[0];
const member = members.find((candidate) => runsServe(candidate, server.args)) ?? members[0];
if (owner === undefined || member === undefined) {
if (groupExists(pgid)) {
yield* Effect.logWarning(
Expand All @@ -212,6 +216,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
pid: member.pid,
startTime: member.startTime,
command: member.command,
args: server.args,
port: server.port,
stateDir: input.stateDir,
owner,
Expand Down Expand Up @@ -239,7 +244,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
);

// Signals only a group whose recorded member still has the same pid, start
// time, group, and full command line, so a recycled pid is never touched.
// time, and group and still has the same command line or serve arguments, so
// a recycled pid is never touched.
const stopOrphan = Effect.fn("OpenCodeServerLedger.stopOrphan")(function* (
entry: OpenCodeServerEntry,
) {
Expand All @@ -249,7 +255,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
observed.zombie ||
observed.startTime !== entry.startTime ||
observed.pgid !== entry.pgid ||
observed.command !== entry.command
(observed.command !== entry.command &&
(entry.args === undefined || !runsServe(observed, entry.args)))
) {
return;
}
Expand Down
Loading