diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index ad8d4126d454..500040dc9aa7 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -93,6 +93,16 @@ const toPlatformError = ( } type ExitSignal = Deferred.Deferred +// Node emits `exit` when the process ends and `close` only after shared stdio closes. +// Descendants can inherit those handles, so process and stdio lifetimes are distinct. +type ProcessLifecycle = { + readonly exited: ExitSignal + readonly closed: ExitSignal +} +type SpawnedProcess = { + readonly proc: NodeChildProcess.ChildProcess + readonly lifecycle: ProcessLifecycle +} export const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -263,24 +273,24 @@ export const make = Effect.gen(function* () { } const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => - Effect.callback((resume) => { - const signal = Deferred.makeUnsafe() + Effect.callback((resume) => { + const lifecycle: ProcessLifecycle = { + exited: Deferred.makeUnsafe(), + closed: Deferred.makeUnsafe(), + } const proc = launch(command.command, command.args, opts) - let end = false - let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined proc.on("error", (err) => { resume(Effect.fail(toPlatformError("spawn", err, command))) }) proc.on("exit", (...args) => { - exit = args + Deferred.doneUnsafe(lifecycle.exited, Exit.succeed(args)) }) proc.on("close", (...args) => { - if (end) return - end = true - Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) + Deferred.doneUnsafe(lifecycle.closed, Exit.succeed(args)) + Deferred.doneUnsafe(lifecycle.exited, Exit.succeed(args)) }) proc.on("spawn", () => { - resume(Effect.succeed([proc, signal])) + resume(Effect.succeed({ proc, lifecycle })) }) return Effect.sync(() => { proc.kill("SIGTERM") @@ -319,26 +329,51 @@ export const make = Effect.gen(function* () { return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) }) - const timeout = - ( - proc: NodeChildProcess.ChildProcess, - command: ChildProcess.StandardCommand, - opts: ChildProcess.KillOptions | undefined, - ) => - ( - f: ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - signal: NodeJS.Signals, - ) => Effect.Effect, - ) => { - const signal = opts?.killSignal ?? "SIGTERM" - if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal) - return Effect.timeoutOrElse(f(command, proc, signal), { - duration: opts.forceKillAfter, - orElse: () => f(command, proc, "SIGKILL"), - }) - } + const signalProcessGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)) + + const terminateProcessGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + lifecycle: ProcessLifecycle, + opts?: ChildProcess.KillOptions, + ) => { + const graceful = signalProcessGroup(command, proc, opts?.killSignal ?? "SIGTERM").pipe( + Effect.andThen(Deferred.await(lifecycle.closed)), + Effect.asVoid, + ) + if (!opts?.forceKillAfter) return graceful + return Effect.timeoutOrElse(graceful, { + duration: opts.forceKillAfter, + // Preserve the existing escalation policy, but stop waiting for shared stdio: + // an escaped descendant can retain those handles after the process exits. + orElse: () => + signalProcessGroup(command, proc, "SIGKILL").pipe( + Effect.andThen(Deferred.await(lifecycle.exited)), + Effect.asVoid, + ), + }) + } + + const reapFailedProcessGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + lifecycle: ProcessLifecycle, + ) => { + const signal = signalProcessGroup(command, proc, command.options.killSignal ?? "SIGTERM") + if (!command.options.forceKillAfter) return signal + return signal.pipe( + Effect.andThen(Deferred.await(lifecycle.closed)), + Effect.timeoutOrElse({ + duration: command.options.forceKillAfter, + orElse: () => signalProcessGroup(command, proc, "SIGKILL"), + }), + Effect.asVoid, + ) + } const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => { const opt = from ?? "stdout" @@ -368,7 +403,7 @@ export const make = Effect.gen(function* () { const extra = fds(command.options) const dir = yield* cwd(command.options) - const [proc, signal] = yield* Effect.acquireRelease( + const spawned = yield* Effect.acquireRelease( spawn(command, { cwd: dir, env: env(command.options), @@ -377,42 +412,34 @@ export const make = Effect.gen(function* () { shell: command.options.shell, windowsHide: process.platform === "win32", }), - Effect.fnUntraced(function* ([proc, signal]) { - const done = yield* Deferred.isDone(signal) - const kill = timeout(proc, command, command.options) - if (done) { - const [code] = yield* Deferred.await(signal) + Effect.fnUntraced(function* (spawned) { + const exited = yield* Deferred.isDone(spawned.lifecycle.exited) + if (exited) { + const [code] = yield* Deferred.await(spawned.lifecycle.exited) if (process.platform === "win32") return yield* Effect.void - if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) + if (code !== 0 && Predicate.isNotNull(code)) + return yield* Effect.ignore(reapFailedProcessGroup(command, spawned.proc, spawned.lifecycle)) return yield* Effect.void } - const send = (s: NodeJS.Signals) => - Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s)) - const sig = command.options.killSignal ?? "SIGTERM" - const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid) - const escalated = command.options.forceKillAfter - ? Effect.timeoutOrElse(attempt, { - duration: command.options.forceKillAfter, - orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), - }) - : attempt - return yield* Effect.ignore(escalated) + return yield* Effect.ignore( + terminateProcessGroup(command, spawned.proc, spawned.lifecycle, command.options), + ) }), ) - const fd = yield* setupFds(command, proc, extra) - const out = setupOutput(command, proc, sout, serr) + const fd = yield* setupFds(command, spawned.proc, extra) + const out = setupOutput(command, spawned.proc, sout, serr) let ref = true return makeHandle({ - pid: ProcessId(proc.pid!), - stdin: yield* setupStdin(command, proc, sin), + pid: ProcessId(spawned.proc.pid!), + stdin: yield* setupStdin(command, spawned.proc, sin), stdout: out.stdout, stderr: out.stderr, all: out.all, getInputFd: fd.getInputFd, getOutputFd: fd.getOutputFd, - isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), - exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { + isRunning: Effect.map(Deferred.isDone(spawned.lifecycle.exited), (done) => !done), + exitCode: Effect.flatMap(Deferred.await(spawned.lifecycle.exited), ([code, signal]) => { if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) return Effect.fail( toPlatformError( @@ -422,25 +449,16 @@ export const make = Effect.gen(function* () { ), ) }), - kill: (opts?: ChildProcess.KillOptions) => { - const sig = opts?.killSignal ?? "SIGTERM" - const send = (s: NodeJS.Signals) => - Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s)) - const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid) - if (!opts?.forceKillAfter) return attempt - return Effect.timeoutOrElse(attempt, { - duration: opts.forceKillAfter, - orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), - }) - }, + kill: (opts?: ChildProcess.KillOptions) => + terminateProcessGroup(command, spawned.proc, spawned.lifecycle, opts), unref: Effect.sync(() => { if (ref) { - proc.unref() + spawned.proc.unref() ref = false } return Effect.sync(() => { if (!ref) { - proc.ref() + spawned.proc.ref() ref = true } }) diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index 8a2fab493063..c84c801d4cda 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -58,6 +58,26 @@ async function gone(pid: number, timeout = 5_000) { return !alive(pid) } +async function readPid(file: string, timeout = 5_000) { + const end = Date.now() + timeout + while (Date.now() < end) { + const value = await fs.readFile(file, "utf-8").catch(() => undefined) + if (value) return Number(value) + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error(`Process did not write its pid to ${file}`) +} + +function stubbornDescendant(pidFile: string, parent: string, opts?: ChildProcess.CommandOptions) { + const code = [ + 'const cp = require("node:child_process")', + 'const fs = require("node:fs")', + 'cp.spawn(process.execPath, ["-e", "const fs = require(\\"node:fs\\"); process.on(\\"SIGTERM\\", () => {}); fs.writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)", process.argv[1]], { stdio: "inherit" })', + parent, + ].join("\n") + return ChildProcess.make(process.execPath, ["-e", code, pidFile], opts) +} + describe("cross-spawn spawner", () => { describe("basic spawning", () => { fx.effect( @@ -96,6 +116,26 @@ describe("cross-spawn spawner", () => { expect(code).toBe(ChildProcessSpawner.ExitCode(42)) }), ) + + fx.live( + "returns exit code when a detached child keeps stdio open", + Effect.gen(function* () { + const started = Date.now() + const handle = yield* ChildProcess.make(process.execPath, [ + "-e", + [ + 'const cp = require("node:child_process")', + 'const child = cp.spawn(process.execPath, ["-e", "setTimeout(() => {}, 3000)"], { detached: true, stdio: "inherit" })', + "child.unref()", + "process.exit(0)", + ].join("\n"), + ]) + const code = yield* handle.exitCode + + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + expect(Date.now() - started).toBeLessThan(2_000) + }), + ) }) describe("cwd option", () => { @@ -194,7 +234,7 @@ describe("cross-spawn spawner", () => { fx.effect( "captures stdout via .all when no stderr", Effect.gen(function* () { - const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) + const handle = yield* ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("hello from stdout")']) const all = yield* decodeByteStream(handle.all) expect(all).toBe("hello from stdout") }), @@ -275,6 +315,135 @@ describe("cross-spawn spawner", () => { }), ) + fx.live( + "forceKillAfter escalates when the parent exits before a stubborn descendant", + Effect.gen(function* () { + if (process.platform === "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const pidFile = path.join(tmp.path, "stubborn-child.pid") + const handle = yield* stubbornDescendant(pidFile, "setInterval(() => {}, 1000)") + const pid = yield* Effect.promise(() => readPid(pidFile)) + + yield* handle.kill({ forceKillAfter: 100 }).pipe(Effect.ignore) + const terminated = yield* Effect.promise(() => gone(pid, 1_000)) + if (!terminated) { + yield* Effect.sync(() => process.kill(pid, "SIGKILL")) + } + expect(terminated).toBe(true) + }), + 10_000, + ) + + fx.live( + "scope cleanup escalates after a failed parent leaves a stubborn descendant", + Effect.gen(function* () { + if (process.platform === "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const pidFile = path.join(tmp.path, "failed-parent-child.pid") + const pid = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* stubbornDescendant( + pidFile, + "const ready = setInterval(() => { if (fs.existsSync(process.argv[1])) { clearInterval(ready); process.exit(42) } }, 10)", + { forceKillAfter: 100 }, + ) + expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(42)) + return yield* Effect.promise(() => readPid(pidFile)) + }), + ) + + const terminated = yield* Effect.promise(() => gone(pid, 1_000)) + if (!terminated) { + yield* Effect.sync(() => process.kill(pid, "SIGKILL")) + } + expect(terminated).toBe(true) + }), + 10_000, + ) + + fx.live( + "scope cleanup returns when a failed parent leaves a descendant holding stdio", + Effect.gen(function* () { + if (process.platform === "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const pidFile = path.join(tmp.path, "failed-parent-no-force.pid") + + yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* stubbornDescendant( + pidFile, + "const ready = setInterval(() => { if (fs.existsSync(process.argv[1])) { clearInterval(ready); process.exit(7) } }, 10)", + ) + expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(7)) + }), + ) + + const pid = yield* Effect.promise(() => readPid(pidFile).catch(() => 0)) + if (pid) { + yield* Effect.sync(() => { + try { + process.kill(pid, "SIGKILL") + } catch {} + }) + } + }), + 10_000, + ) + + fx.live( + "kill escalation returns when a stubborn descendant escapes the process group", + Effect.gen(function* () { + if (process.platform === "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const pidFile = path.join(tmp.path, "escaped-descendant.pid") + const code = [ + 'const cp = require("node:child_process")', + 'const fs = require("node:fs")', + 'const child = cp.spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "inherit" })', + "child.unref()", + "fs.writeFileSync(process.argv[1], String(child.pid))", + 'process.on("SIGTERM", () => {})', + "setInterval(() => {}, 1000)", + ].join("\n") + + const outcome = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make(process.execPath, ["-e", code, pidFile]) + yield* Effect.promise(() => readPid(pidFile)) + return yield* handle.kill({ forceKillAfter: 100 }).pipe(Effect.as("returned" as const)) + }), + ) + + const pid = yield* Effect.promise(() => readPid(pidFile).catch(() => 0)) + if (pid) { + yield* Effect.sync(() => { + try { + process.kill(pid, "SIGKILL") + } catch {} + }) + } + + expect(outcome).toBe("returned") + }), + 10_000, + ) + fx.effect( "isRunning reflects process state", Effect.gen(function* () { diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index b6a95b5c0970..d9de643adb9c 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,4 +1,4 @@ -import { Effect, Stream } from "effect" +import { Cause, Effect, Fiber, Queue, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -19,7 +19,7 @@ import { ShellID } from "./shell/id" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { ChildProcess } from "effect/unstable/process" -import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { ChildProcessSpawner, type ExitCode } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" import { BashArity } from "@/permission/arity" @@ -82,6 +82,12 @@ type Chunk = { size: number } +const ShellCompletion = { + exited: (code: ExitCode) => ({ _tag: "Exited" as const, code }), + aborted: { _tag: "Aborted" as const }, + timedOut: { _tag: "TimedOut" as const }, +} + export const log = Log.create({ service: "shell-tool" }) const resolveWasm = (asset: string) => { @@ -481,8 +487,25 @@ export const ShellTool = Tool.define( yield* Effect.addFinalizer(closeSink) const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env)) - yield* Effect.forkScoped( - Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { + const outputChunks = yield* Queue.bounded(16) + const metadataUpdates = yield* Queue.sliding(1) + const publishMetadata = (output: string) => + Effect.sync(() => { + Queue.offerUnsafe(metadataUpdates, output) + }) + const metadataPublication = yield* Stream.fromQueue(metadataUpdates).pipe( + Stream.runForEach((output) => + ctx.metadata({ + metadata: { + output, + description: input.description, + }, + }), + ), + Effect.forkScoped, + ) + const outputPersistence = yield* Stream.fromQueue(outputChunks).pipe( + Stream.runForEach((chunk) => { const size = Buffer.byteLength(chunk, "utf-8") list.push({ text: chunk, size }) used += size @@ -509,26 +532,27 @@ export const ShellTool = Tool.define( full = "" }), ), - Effect.andThen( - ctx.metadata({ - metadata: { - output: last, - description: input.description, - }, - }), - ), + Effect.andThen(publishMetadata(last)), ) } } - return ctx.metadata({ - metadata: { - output: last, - description: input.description, - }, - }) + return publishMetadata(last) }), + Effect.forkScoped, ) + const outputCapture = yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => + Queue.offer(outputChunks, chunk).pipe(Effect.asVoid), + ).pipe(Effect.ensuring(Queue.end(outputChunks)), Effect.forkScoped) + + // Foreground exit is the output boundary. Descendants may retain the inherited + // pipe indefinitely, so stop accepting bytes and persist only captured chunks. + const finalizeOutput = Effect.fnUntraced(function* () { + yield* Fiber.interrupt(outputCapture) + yield* Fiber.join(outputPersistence).pipe(Effect.ignore) + yield* Queue.end(metadataUpdates) + yield* Fiber.join(metadataPublication).pipe(Effect.ignore) + }) const abort = Effect.callback((resume) => { if (ctx.abort.aborted) return resume(Effect.void) @@ -539,22 +563,28 @@ export const ShellTool = Tool.define( const timeout = Effect.sleep(`${input.timeout + 100} millis`) - const exit = yield* Effect.raceAll([ - handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), - abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), - timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), + const completion = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map(ShellCompletion.exited)), + abort.pipe(Effect.as(ShellCompletion.aborted)), + timeout.pipe(Effect.as(ShellCompletion.timedOut)), ]) - if (exit.kind === "abort") { - aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { - expired = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + switch (completion._tag) { + case "Exited": + break + case "Aborted": + aborted = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + break + case "TimedOut": + expired = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + break } - return exit.kind === "exit" ? exit.code : null + yield* finalizeOutput() + + return completion._tag === "Exited" ? completion.code : null }), ).pipe(Effect.orDie) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 08251f9def8c..12a710c99148 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1083,6 +1083,66 @@ describe("tool.shell abort", () => { 15_000, ) + it.live( + "returns when a detached child keeps stdio open", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const pidFile = path.join(dir, "daemon.pid") + const code = + 'const cp=require("node:child_process");const fs=require("node:fs");' + + 'const child=cp.spawn(process.execPath,["-e","setTimeout(()=>{},60000)"],{detached:true,stdio:"inherit"});' + + 'child.unref();fs.writeFileSync(Bun.argv[1],String(child.pid));process.stdout.write("STARTED");process.exit(0)' + const command = `${bin} -e ${evalarg(code)} ${quote(pidFile)}` + const started = Date.now() + const result = yield* run({ + command: PS.has(sh()) ? `& ${command}` : command, + description: "Detached child", + timeout: 30_000, + }) + const pid = Number(yield* (yield* AppFileSystem.Service).readFileString(pidFile)) + yield* Effect.sync(() => process.kill(pid)) + + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("STARTED") + expect(Date.now() - started).toBeLessThan(5_000) + }), + ), + 15_000, + ) + + it.live( + "returns when a detached child keeps writing output", + () => + runIn( + projectRoot, + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const pidFile = path.join(dir, "logging-daemon.pid") + const code = + 'const cp=require("node:child_process");const fs=require("node:fs");' + + 'const child=cp.spawn(process.execPath,["-e","setInterval(()=>process.stdout.write(\\"tick\\"),10)"],{detached:true,stdio:"inherit"});' + + 'child.unref();fs.writeFileSync(Bun.argv[1],String(child.pid));process.stdout.write("STARTED");process.exit(0)' + const command = `${bin} -e ${evalarg(code)} ${quote(pidFile)}` + const started = Date.now() + const result = yield* run({ + command: PS.has(sh()) ? `& ${command}` : command, + description: "Logging detached child", + timeout: 30_000, + }) + const pid = Number(yield* (yield* AppFileSystem.Service).readFileString(pidFile)) + yield* Effect.sync(() => process.kill(pid)) + + expect(result.metadata.exit).toBe(0) + expect(result.output).toContain("STARTED") + expect(Date.now() - started).toBeLessThan(5_000) + }), + ), + 15_000, + ) + it.live( "uses RuntimeFlags bashDefaultTimeoutMs when timeout is omitted", () => @@ -1231,4 +1291,33 @@ describe("tool.shell truncation", () => { }), ), ) + + it.live("saves all fast output while metadata processing is delayed", () => + runIn( + projectRoot, + Effect.gen(function* () { + const byteCount = 1_000_000 + let delayed = false + const result = yield* run( + { + command: fill("bytes", byteCount), + description: "Generate fast output with delayed metadata", + }, + { + ...ctx, + metadata: (input) => { + const output = (input.metadata as { output?: string })?.output + if (!output || delayed) return Effect.void + delayed = true + return Effect.sleep("1200 millis") + }, + }, + ) + const filepath = (result.metadata as { outputPath?: string }).outputPath + expect(filepath).toBeTruthy() + const saved = yield* (yield* AppFileSystem.Service).readFileString(filepath!) + expect(saved.length).toBe(byteCount) + }), + ), + ) })