Files
tswatch/ts/tswatch.classes.watcher.ts
Juergen Kunz f7f42ff36c
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 39s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
fix(watcher): always tree-kill on stop regardless of childProcess.killed flag
The direct bash child may already be dead from terminal SIGINT while
grandchildren (tsrun, devserver) are still running. Removing the .killed
guard ensures tree-kill always runs to clean up the entire process tree.
2026-03-04 00:09:21 +00:00

212 lines
5.9 KiB
TypeScript

import * as plugins from './tswatch.plugins.js';
import * as interfaces from './interfaces/index.js';
import { logger } from './tswatch.logging.js';
export interface IWatcherConstructorOptions {
/** Name for this watcher (used in logging) */
name?: string;
/** Path(s) to watch - can be a single path or array */
filePathToWatch: string | string[];
/** Shell command to execute on changes */
commandToExecute?: string;
/** Function to call on changes */
functionToCall?: () => Promise<any>;
/** Timeout for the watcher */
timeout?: number;
/** If true, kill previous process before restarting (default: true) */
restart?: boolean;
/** Debounce delay in ms (default: 300) */
debounce?: number;
/** If true, run the command immediately on start (default: true) */
runOnStart?: boolean;
}
/**
* A watcher keeps track of one child execution
*/
export class Watcher {
/**
* used to execute shell commands
*/
private smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
});
private currentExecution: plugins.smartshell.IExecResultStreaming;
private smartwatchInstance = new plugins.smartwatch.Smartwatch([]);
private options: IWatcherConstructorOptions;
private debounceTimer: NodeJS.Timeout | null = null;
private isExecuting = false;
private pendingExecution = false;
constructor(optionsArg: IWatcherConstructorOptions) {
this.options = {
restart: true,
debounce: 300,
runOnStart: true,
...optionsArg,
};
}
/**
* Create a Watcher from config
*/
public static fromConfig(config: interfaces.IWatcherConfig): Watcher {
const watchPaths = Array.isArray(config.watch) ? config.watch : [config.watch];
return new Watcher({
name: config.name,
filePathToWatch: watchPaths,
commandToExecute: config.command,
restart: config.restart ?? true,
debounce: config.debounce ?? 300,
runOnStart: config.runOnStart ?? true,
});
}
/**
* Get the watcher name for logging
*/
private getName(): string {
return this.options.name || 'unnamed';
}
/**
* start the file
*/
public async start() {
const name = this.getName();
logger.log('info', `[${name}] starting watcher`);
await this.setupCleanup();
// Convert paths to glob patterns
const paths = Array.isArray(this.options.filePathToWatch)
? this.options.filePathToWatch
: [this.options.filePathToWatch];
const watchPatterns = paths.map((p) => {
// Convert directory path to glob pattern for smartwatch
if (p.endsWith('/')) {
return `${p}**/*`;
}
// If it's already a glob pattern, use as-is
if (p.includes('*')) {
return p;
}
// Otherwise assume it's a directory
return `${p}/**/*`;
});
logger.log('info', `[${name}] watching patterns: ${watchPatterns.join(', ')}`);
this.smartwatchInstance.add(watchPatterns);
await this.smartwatchInstance.start();
const changeObservable = await this.smartwatchInstance.getObservableFor('change');
changeObservable.subscribe(() => {
this.handleChange();
});
// Run on start if configured
if (this.options.runOnStart) {
await this.executeCommand();
}
logger.log('info', `[${name}] watcher started`);
}
/**
* Handle file change with debouncing
*/
private handleChange() {
const name = this.getName();
// Clear existing debounce timer
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Set new debounce timer
this.debounceTimer = setTimeout(async () => {
this.debounceTimer = null;
// If currently executing and not in restart mode, mark pending
if (this.isExecuting && !this.options.restart) {
logger.log('info', `[${name}] change detected, queuing execution`);
this.pendingExecution = true;
return;
}
await this.executeCommand();
// If there was a pending execution, run it
if (this.pendingExecution) {
this.pendingExecution = false;
await this.executeCommand();
}
}, this.options.debounce);
}
/**
* Execute the command or function
*/
private async executeCommand() {
const name = this.getName();
if (this.options.commandToExecute) {
if (this.currentExecution && this.options.restart) {
logger.log('ok', `[${name}] restarting: ${this.options.commandToExecute}`);
await this.currentExecution.kill();
} else if (!this.currentExecution) {
logger.log('ok', `[${name}] executing: ${this.options.commandToExecute}`);
}
this.isExecuting = true;
this.currentExecution = await this.smartshellInstance.execStreaming(
this.options.commandToExecute,
);
// Track when execution completes
this.currentExecution.childProcess.on('exit', () => {
this.isExecuting = false;
});
}
if (this.options.functionToCall) {
this.isExecuting = true;
try {
await this.options.functionToCall();
} finally {
this.isExecuting = false;
}
}
}
/**
* Sets up timeout-based cleanup if configured.
* Signal handling (SIGINT/SIGTERM) is managed globally by ProcessLifecycle in TsWatch.
*/
private async setupCleanup() {
if (this.options.timeout) {
plugins.smartdelay.delayFor(this.options.timeout).then(async () => {
console.log(`timed out after ${this.options.timeout} milliseconds! exiting!`);
await this.stop();
process.exit(0);
});
}
}
/**
* stops the watcher
*/
public async stop() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
await this.smartwatchInstance.stop();
if (this.currentExecution) {
// Always tree-kill — even if the direct child is dead (.killed === true),
// grandchildren (e.g. tsrun, devserver) may still be running.
await this.currentExecution.kill();
}
}
}