fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)

* fix: harden task lifecycle and scheduler readiness

* fix: confine log writes to the configured log directory

* fix: verify complete build artifacts and untracked inputs

* fix: reconcile scheduler state and make stop win startup races

* fix: isolate cron generations and serialize scheduler recovery
This commit is contained in:
whyour
2026-09-13 00:32:41 +08:00
committed by GitHub
parent d62d8f3025
commit a94e665054
61 changed files with 4214 additions and 608 deletions
+87 -73
View File
@@ -1,5 +1,8 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
import path from 'path';
import config from '../config';
import { resolveFileAccess } from './fileAccess';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
@@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
private closingStreams = new Map<string, Promise<void>>();
private closedStreams = new WeakSet<WriteStream>();
private streamErrors = new Map<string, Error>();
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
constructor(private readonly logRoot = config.logPath) {
super();
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
/** Register each write synchronously, so concurrent callers cannot lose the tail. */
async write(filePath: string, data: string): Promise<void> {
if (this.closingStreams.has(filePath)) {
throw new Error(`Log stream is closing: ${filePath}`);
}
const previous = this.pendingWrites.get(filePath) || Promise.resolve();
const pending = previous.then(
() =>
new Promise<void>((resolve, reject) => {
const failure = this.streamErrors.get(filePath);
if (failure) return reject(failure);
let stream = this.streams.get(filePath);
if (!stream) {
// Validate only when opening: subsequent chunks reuse the same descriptor.
const root = path.resolve(this.logRoot);
const target = path.resolve(filePath);
if (
!target.startsWith(root + path.sep) ||
!resolveFileAccess(root, [target])
) {
return reject(new Error('Log path is outside the log directory'));
}
stream = createWriteStream(target, { flags: 'a' });
this.streams.set(filePath, stream);
const current = stream;
stream.once('close', () => this.closedStreams.add(current));
stream.on('error', (error) => {
this.streamErrors.set(filePath, error);
// EventEmitter's unobserved "error" event would crash the caller.
if (this.listenerCount('error') > 0)
this.emit('error', { filePath, error });
});
}
stream.write(data, 'utf8', (error) =>
error ? reject(error) : resolve(),
);
}),
);
this.pendingWrites.set(filePath, pending);
// Keep the tail until close, including failures; never reopen a failed log mid-run.
return pending;
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
async closeStream(filePath: string): Promise<void> {
const closing = this.closingStreams.get(filePath);
if (closing) return closing;
const pending = this.pendingWrites.get(filePath);
const result = (async () => {
let failure: unknown;
try {
await pending;
} catch (error) {
failure = error;
}
const stream = this.streams.get(filePath);
try {
if (stream && !this.closedStreams.has(stream)) {
await new Promise<void>((resolve) => {
stream.once('close', resolve);
if (failure || stream.destroyed) stream.destroy();
else stream.end();
});
}
failure ||= this.streamErrors.get(filePath);
if (failure) throw failure;
} finally {
this.streams.delete(filePath);
this.pendingWrites.delete(filePath);
this.streamErrors.delete(filePath);
}
})();
this.closingStreams.set(filePath, result);
try {
await result;
} finally {
this.closingStreams.delete(filePath);
}
}
@@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter {
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
const paths = new Set([
...this.streams.keys(),
...this.pendingWrites.keys(),
]);
const closePromises = Array.from(paths).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);