Files
opencode/packages/core/test/shell-retention.test.ts

100 lines
4.2 KiB
TypeScript

import { expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Queue, Scope, Sink, Stream } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Config } from "@opencode/core/config"
import { Environment } from "@opencode/core/environment/index"
import { Location } from "@opencode/core/location"
import { Shell } from "@opencode/core/shell"
import { Global } from "@opencode/util/global"
import { hostEnvironmentLayer } from "./fixture/environment"
import { tempGlobalLayer } from "./fixture/global"
import { tempLocationLayer } from "./fixture/location"
import { it } from "./lib/effect"
it.live("eviction makes progress past an already-removed shell", () =>
Effect.gen(function* () {
const completions = yield* Queue.unbounded<Effect.Effect<void>>()
const environment = Layer.effect(
Environment.Service,
Effect.gen(function* () {
const host = yield* Environment.Service
const scope = yield* Scope.Scope
return Environment.Service.of({
...host,
spawner: ChildProcessSpawner.make(() =>
Effect.gen(function* () {
const exited = yield* Deferred.make<ExitCode>()
const observer = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
yield* Queue.offer(
completions,
Effect.gen(function* () {
yield* Deferred.succeed(exited, ExitCode(0))
// Shell.wait resolves before retention runs; join the whole exit handler instead.
const fiber = yield* Deferred.await(observer)
yield* Fiber.join(fiber).pipe(Effect.orDie)
}),
)
const output = Stream.succeed(Buffer.from("hello"))
return makeHandle({
pid: ProcessId(1),
exitCode: Effect.withFiber((fiber) =>
Scope.addFinalizer(scope, Fiber.interrupt(fiber)).pipe(
Effect.andThen(Deferred.succeed(observer, fiber)),
Effect.andThen(Deferred.await(exited)),
),
),
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
kill: () => Deferred.succeed(exited, ExitCode(0)).pipe(Effect.asVoid),
stdin: Sink.drain,
stdout: output,
stderr: Stream.empty,
all: output,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
})
}),
),
})
}),
).pipe(Layer.provide(hostEnvironmentLayer))
yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const removed = yield* shell.create({ shell: "sh", command: "removed", timeout: 0 })
const finishRemoved = yield* Queue.take(completions)
yield* shell.remove(removed.id)
expect((yield* shell.result(removed)).capture).toBeUndefined()
yield* finishRemoved
const complete = Effect.gen(function* () {
const info = yield* shell.create({ shell: "sh", command: "hello", timeout: 0 })
const finish = yield* Queue.take(completions)
yield* finish
return info
})
const oldest = yield* complete
// Exceed the 25-entry retention cap with the removed ID at the head of exitOrder.
yield* Effect.forEach(Array.from({ length: 25 }), () => complete, { discard: true })
expect(yield* shell.get(oldest.id).pipe(Effect.flip)).toBeInstanceOf(Shell.NotFoundError)
const survivor = yield* complete
expect(yield* shell.result(survivor)).toMatchObject({
info: { status: "exited", exit: 0 },
capture: { output: "hello", truncated: false },
})
}).pipe(
Effect.provide(
AppNodeBuilder.build(Shell.node, [
Location.node.replace(tempLocationLayer),
Global.node.replace(tempGlobalLayer),
Config.node.replace(Config.testLayer()),
Environment.node.replace(environment),
]),
),
)
}),
)