fix(hub): do not crash Hub on missing DocMind input files

createReadStream emits async ENOENT without a listener, which became an
unhandled 'error' event and exited the silo process. Teachers then saw the
startup "process restart" notice. Wait for stream open and convert missing
files into DocmindClientError instead.
This commit is contained in:
2026-07-27 12:25:04 +08:00
parent bdd722c463
commit 34d5d5e88e
2 changed files with 70 additions and 6 deletions
+34 -4
View File
@@ -19,7 +19,8 @@ import $DocmindClient, {
QueryDocParserStatusRequest,
} from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { createReadStream, type ReadStream } from "node:fs";
import { once } from "node:events";
import { basename } from "node:path";
import type { DocmindCapabilitySecretPayload } from "./types.js";
@@ -66,6 +67,11 @@ type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
// Open first so missing local inputs fail closed before touching the SDK.
// Unhandled createReadStream('error') previously crashed the Hub process.
const fileName = basename(options.inputFilePath);
const fileStream = await openLocalFileStream(options.inputFilePath);
const config: DocmindConfig = {
endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId,
@@ -75,11 +81,9 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName,
@@ -91,6 +95,7 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) {
fileStream.destroy();
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
@@ -229,3 +234,28 @@ function extractFilename(altText: string, url: string, index: number): string {
if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`;
}
/**
* Open a local file as a ReadStream only after the fd is successfully open.
* createReadStream() emits asynchronous 'error' for missing paths; without a
* listener that becomes an unhandled EventEmitter error and exits Node.
*/
async function openLocalFileStream(path: string): Promise<ReadStream> {
const stream = createReadStream(path);
try {
await once(stream, "open");
} catch (error) {
stream.destroy();
const err = error instanceof Error ? error : new Error(String(error));
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new DocmindClientError(`input file not found: ${path}`, "docmind_rejected");
}
throw new DocmindClientError(err.message, "docmind_unreachable");
}
// After open, residual stream errors must not become unhandled and crash Hub.
stream.on("error", () => {
// The Aliyun SDK / destroy path owns consumption failures after open.
});
return stream;
}
+34
View File
@@ -0,0 +1,34 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { AliyunDocmindClient, DocmindClientError } from "../../src/capability/docmindClient.js";
describe("AliyunDocmindClient local file open", () => {
it("rejects a missing input file without crashing the process", async () => {
const client = new AliyunDocmindClient();
const missing = join(tmpdir(), `docmind-missing-${Date.now()}.pdf`);
// If createReadStream errors are left unhandled, Vitest aborts the suite
// with an unhandled 'error' event instead of reaching this assertion.
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toBeInstanceOf(DocmindClientError);
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toMatchObject({
code: "docmind_rejected",
message: expect.stringContaining("input file not found"),
});
});
});