forked from EduCraft/curriculum-project-hub
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:
@@ -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
|
||||
// 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);
|
||||
// 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 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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user