From 41cfb5a95969689c913652cc125cbdf215646bb0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:37:43 +0000 Subject: [PATCH 1/4] test(opencode): ledger stops a wrapper's server recorded before it execs The reaper skips a wrapper's server group when the ledger recorded the forked child before it exec'd, because the recorded command line no longer matches. Gate the child on a fifo so the test records it pre-exec. Fails on main; reported in #22324 item 4. AI-assisted; verified with the tests and tools listed above. --- .../src/server/OpenCodeServerLedger.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 1310cdd8b36e0..41e24f428fec9 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -152,18 +152,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; From 4697608d624b094463243bef8867f25cfef09052 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:42:35 +0000 Subject: [PATCH 2/4] fix(opencode): restarted server stops an orphaned OpenCode server started through a wrapper The ledger recorded the member's full command line, which for a wrapper's forked child can be the pre-exec line, and the reaper required an exact match, so it treated the orphan as a recycled pid and left it running. Record the serve arguments instead and match on them both when tracking and when reaping. Fixes pingdotgg/t3code#22324 AI-assisted; verified with the tests and tools listed above. --- .../src/server/OpenCodeServerLedger.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index af90d19acd67d..827767b0ff985 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,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 +198,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( @@ -207,11 +210,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, @@ -239,7 +242,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 serve arguments, so a recycled pid is + // never touched. const stopOrphan = Effect.fn("OpenCodeServerLedger.stopOrphan")(function* ( entry: OpenCodeServerEntry, ) { @@ -249,7 +253,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; } From a92ac703a5426246db9048e89f80aa6b859c6663 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:54:50 +0000 Subject: [PATCH 3/4] fix(opencode): keep older ledger entries and exact-command matches Keep the version-1 entry format with its full command line and add the serve arguments as an optional field. The reaper stops a recorded group when the command line still matches exactly or, for entries that carry them, when it still ends with the serve arguments. Entries written by an older server still decode and are reaped as before, and a wrapper that appends its own options is still recorded and stopped by its command line. AI-assisted; verified with the tests and tools listed above. --- .../src/server/OpenCodeServerLedger.test.ts | 34 ++++++++++++++++++- .../src/server/OpenCodeServerLedger.ts | 17 ++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 41e24f428fec9..8be39280b4087 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -176,6 +176,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; @@ -204,7 +236,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 827767b0ff985..fb3ea7645e9bf 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -16,11 +16,12 @@ const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.Strin type ProcessIdentity = typeof ProcessIdentity.Type; const OpenCodeServerEntry = Schema.Struct({ - version: Schema.Literal(2), + version: Schema.Literal(1), pgid: Schema.Int, pid: Schema.Int, startTime: Schema.String, - args: Schema.Array(Schema.String), + command: Schema.String, + args: Schema.optionalKey(Schema.Array(Schema.String)), port: Schema.Int, stateDir: Schema.String, owner: ProcessIdentity, @@ -198,7 +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) => runsServe(candidate, server.args)); + const member = members.find((candidate) => runsServe(candidate, server.args)) ?? members[0]; if (owner === undefined || member === undefined) { if (groupExists(pgid)) { yield* Effect.logWarning( @@ -210,10 +211,11 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { } const entryPath = path.join(directory, `${pgid}.json`); const entry: OpenCodeServerEntry = { - version: 2, + version: 1, pgid, pid: member.pid, startTime: member.startTime, + command: member.command, args: server.args, port: server.port, stateDir: input.stateDir, @@ -242,8 +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, and group and still runs the serve arguments, 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, ) { @@ -253,7 +255,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { observed.zombie || observed.startTime !== entry.startTime || observed.pgid !== entry.pgid || - !runsServe(observed, entry.args) + (observed.command !== entry.command && + (entry.args === undefined || !runsServe(observed, entry.args))) ) { return; } From 7be89fd3b0dfaef14774981367edcb3cbb99597b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:19:23 +0000 Subject: [PATCH 4/4] test(opencode): drop the ledger test helper's unused ready signal Co-Authored-By: Claude Opus 5.5 --- .../src/server/OpenCodeServerLedger.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 8be39280b4087..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