diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 1310cdd8b36e0..3fcb52c898d68 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -56,7 +56,6 @@ const spawnGroupLeader = (args: ReadonlyArray, script: string) => if (pid === undefined) return yield* Effect.die("spawn failed"); const grouped = yield* Deferred.make(); const exited = yield* Deferred.make(); - const printed = yield* Deferred.make(); 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. @@ -77,12 +76,9 @@ const spawnGroupLeader = (args: ReadonlyArray, 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 @@ -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; @@ -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; @@ -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; diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index af90d19acd67d..fb3ea7645e9bf 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -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, @@ -73,6 +74,11 @@ const parseDarwinPs = (output: string): ReadonlyArray => ]; }); +// 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) => + observed.command.endsWith(` ${args.join(" ")}`); + const signalGroup = (pgid: number, signal: NodeJS.Signals) => { try { signalProcessGroup(pgid, signal); @@ -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( @@ -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, @@ -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, ) { @@ -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; }