fix: keep Cloud Web alive on client disconnect

This commit is contained in:
Codex Agent
2026-06-06 13:29:44 +08:00
parent 28a291996e
commit a94b76b94c
4 changed files with 205 additions and 48 deletions
+125 -37
View File
@@ -18,8 +18,16 @@ const GZIP_MIN_BYTES = 1024;
const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_WORKBENCH_PROJECT_ID = "prj_hwpod_workbench";
const CLIENT_DISCONNECT_ERROR_CODES = new Set([
"ECONNRESET",
"EPIPE",
"ERR_STREAM_PREMATURE_CLOSE"
]);
const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu;
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
export async function serveCloudWeb(options) {
installCloudWebProcessErrorHandlers({ serviceId: options.serviceId });
const server = createCloudWebServer(options);
server.listen(options.port, "0.0.0.0", () => {
process.stdout.write(JSON.stringify({
@@ -43,50 +51,130 @@ export function createCloudWebServer({
})
}) {
return createServer(async (request, response) => {
attachRequestErrorHandlers({ request, response, serviceId });
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
if (url.pathname === "/health/live" || url.pathname === "/health") {
sendJson(response, 200, healthPayload());
return;
}
if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) {
return;
}
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
if (proxyPolicy.proxy) {
await proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
return;
}
const routePath = url.pathname.replace(/\/+$/u, "") || "/";
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" || routePath === "/skills" || routePath === "/access"
? "index.html"
: url.pathname.slice(1);
for (const root of roots) {
const candidate = path.resolve(root, relativePath);
if (!candidate.startsWith(root)) continue;
try {
const info = await stat(candidate);
if (!info.isFile()) continue;
const body = await readFile(candidate);
const type = contentType(candidate);
const encoded = await encodeStaticBody(request, body, type);
response.writeHead(200, {
"content-type": type,
...encoded.headers,
"content-length": encoded.body.length
});
response.end(encoded.body);
try {
if (url.pathname === "/health/live" || url.pathname === "/health") {
sendJson(response, 200, healthPayload());
return;
}
if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) {
return;
} catch {
// Try the next root.
}
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
if (proxyPolicy.proxy) {
await proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
return;
}
const routePath = url.pathname.replace(/\/+$/u, "") || "/";
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" || routePath === "/skills" || routePath === "/access"
? "index.html"
: url.pathname.slice(1);
for (const root of roots) {
const candidate = path.resolve(root, relativePath);
if (!candidate.startsWith(root)) continue;
try {
const info = await stat(candidate);
if (!info.isFile()) continue;
const body = await readFile(candidate);
const type = contentType(candidate);
const encoded = await encodeStaticBody(request, body, type);
response.writeHead(200, {
"content-type": type,
...encoded.headers,
"content-length": encoded.body.length
});
response.end(encoded.body);
return;
} catch (error) {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
return;
}
// Try the next root for ordinary filesystem misses.
}
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
} catch (error) {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
return;
}
if (!response.headersSent && !response.writableEnded) {
sendJson(response, 500, {
error: "internal_error",
serviceId,
message: error instanceof Error ? error.message : String(error)
});
return;
}
response.destroy(error);
}
});
}
export function installCloudWebProcessErrorHandlers({ serviceId } = {}) {
if (process[CLOUD_WEB_ERROR_HANDLER_INSTALLED]) return;
process[CLOUD_WEB_ERROR_HANDLER_INSTALLED] = true;
process.on("uncaughtException", (error) => {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, error, source: "uncaughtException" });
return;
}
logCloudWebRuntimeEvent("cloud-web-runtime-fatal", { serviceId, error, source: "uncaughtException" });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, error, source: "unhandledRejection" });
return;
}
logCloudWebRuntimeEvent("cloud-web-runtime-fatal", { serviceId, error, source: "unhandledRejection" });
process.exit(1);
});
}
function attachRequestErrorHandlers({ request, response, serviceId }) {
const handle = (error) => {
if (isClientDisconnectError(error)) {
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
return;
}
throw error;
};
request.on("error", handle);
response.on("error", handle);
}
export function isClientDisconnectError(error) {
const candidate = error instanceof Error ? error : new Error(String(error));
const code = typeof candidate.code === "string" ? candidate.code : "";
return CLIENT_DISCONNECT_ERROR_CODES.has(code) || CLIENT_DISCONNECT_ERROR_PATTERN.test(candidate.message || "");
}
function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null } = {}) {
const payload = {
event,
serviceId: serviceId || "hwlab-cloud-web",
source,
method: request?.method || null,
path: request?.url || null,
error: error
? {
name: error.name || "Error",
code: typeof error.code === "string" ? error.code : null,
message: error.message || String(error)
}
: null,
observedAt: new Date().toISOString()
};
process.stderr.write(JSON.stringify(payload) + "\n");
}
export async function handleCloudWebAuth(options) {
const { request, response, url } = options;
if (url.pathname === "/auth/workspace-bootstrap" && request.method === "GET") {