From 0d718f42b5c697ce0329e02b2bf4f856cf08a94b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 08:07:32 +0000 Subject: [PATCH] fix(opencode): OpenCodeServerLedger linux tests no longer race a wrapper's exec When the spawned OpenCode wrapper had already exited, the ledger could record its forked child before that child exec'd. The recorded full command line was then the wrapper's, so the reaper never matched it again and left the orphaned server running. Record and match on the serve arguments, which survive the exec, instead of the full command line. The test's killGroup finalizer also checked a group and then killed it, which threw ESRCH when the group's last member was reaped in between. Co-Authored-By: Claude Opus 5.5 --- .../src/server/OpenCodeServerLedger.test.ts | 15 ++++++++++-- .../src/server/OpenCodeServerLedger.ts | 23 +++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 040e4ead2b59b..b9f14d89266f6 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -28,9 +28,14 @@ const groupExists = (pgid: number) => { } }; +// A group can empty between any check and the kill, so ESRCH means done. const killGroup = (pgid: number) => Effect.sync(() => { - if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); + try { + process.kill(-pgid, "SIGKILL"); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== "ESRCH") throw cause; + } }); const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => @@ -96,15 +101,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-" }); + 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 "sleep 600 & wait" "$0" "$@" & exit 0', + `(read _ < '${gate}'; exec /bin/sh -c 'echo > "${ready}"; sleep 600 & wait' "$0" "$@") & exit 0`, ); yield* wrapper.exited; 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; diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index e5c1866d5e22e..a43ef90029010 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -16,11 +16,11 @@ const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.Strin type ProcessIdentity = typeof ProcessIdentity.Type; const OpenCodeServerEntry = Schema.Struct({ - version: Schema.Literal(1), + version: Schema.Literal(2), pgid: Schema.Int, pid: Schema.Int, startTime: Schema.String, - command: Schema.String, + args: Schema.Array(Schema.String), port: Schema.Int, stateDir: Schema.String, owner: ProcessIdentity, @@ -73,6 +73,12 @@ const parseDarwinPs = (output: string): ReadonlyArray => ]; }); +// A wrapper's forked child shows the wrapper's command line until it execs, so +// a full command recorded then would never match again. The serve arguments +// survive the exec and still identify the server. +const runsServe = (observed: ObservedProcess, args: ReadonlyArray) => + observed.command.endsWith(` ${args.join(" ")}`); + const signalGroup = (pgid: number, signal: NodeJS.Signals) => { try { signalProcessGroup(pgid, signal); @@ -195,9 +201,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)); if (owner === undefined || member === undefined) { if (groupExists(pgid)) { yield* Effect.logWarning( @@ -209,11 +213,11 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { } const entryPath = path.join(directory, `${pgid}.json`); const entry: OpenCodeServerEntry = { - version: 1, + version: 2, pgid, pid: member.pid, startTime: member.startTime, - command: member.command, + args: server.args, port: server.port, stateDir: input.stateDir, owner, @@ -241,7 +245,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 runs the same serve arguments, so a recycled pid + // is never touched. const stopOrphan = Effect.fn("OpenCodeServerLedger.stopOrphan")(function* ( entry: OpenCodeServerEntry, ) { @@ -251,7 +256,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { observed.zombie || observed.startTime !== entry.startTime || observed.pgid !== entry.pgid || - observed.command !== entry.command + !runsServe(observed, entry.args) ) { return; }