Skip to content

OutputMonitor.continueMonitoringAsync does not cancel the previous monitoring run before starting a new one #309362

@anthonykim1

Description

@anthonykim1

OutputMonitor.continueMonitoringAsync starts a new monitoring run without stopping the previous one:

continueMonitoringAsync(token: CancellationToken): void {
    this._asyncMode = true;
    // Cancel and dispose any in-progress monitoring run to avoid two concurrent loops
    this._currentMonitoringCts?.dispose();
    this._currentMonitoringCts = new CancellationTokenSource(token);
    this._state = OutputMonitorState.PollingForIdle;
    this._startMonitoring(this._command, this._invocationContext, this._currentMonitoringCts.token);
}

The inline comment says "Cancel and dispose any in-progress monitoring run", but the previous run isn't actually cancelled. CancellationTokenSource.dispose() at cancellation.ts#L128 defaults cancel to false:

dispose(cancel: boolean = false): void {
    if (cancel) {
        this.cancel();
    }
    ...
}

So disposing _currentMonitoringCts doesn't signal cancellation to the in-flight _startMonitoring call. The old run's token stays in its original state and the loop keeps progressing through _waitForIdle, _handleIdleState, and its finally block.

The foreground→background handoff in runInTerminalTool calls continueMonitoringAsync while the original monitoring pass is still running. With the cancellation bug above, that path can have two monitoring runs alive at once. OutputMonitor keeps per-run state on the instance, so both runs share this._state, this._pollingResult, this._userInputListener, and the emitters.

The stale-run finally at outputMonitor.ts#L187 is the strongest correctness problem:

} finally {
    this._pollingResult = {
        state: this._state,
        output: output ?? this._execution.getOutput(),
        pollDurationMs: Date.now() - pollStartTime,
        resources
    };
    this._userInputListener.clear();
    this._onDidFinishCommand.fire();
}

There's no ownership check against the active monitoring run. A stale run can overwrite _pollingResult, clear _userInputListener, and fire onDidFinishCommand after a newer run has already taken over. If it reaches one of the input-required branches at outputMonitor.ts#L273, #L290, or #L302 before unwinding, it can also fire _onDidDetectInputNeeded against the new run's state.

Scoping: the initial async-mode flow from the constructor isn't affected because the caller waits for the first monitoring pass to finish before continueMonitoringAsync runs. This is specifically about the foreground→background transition path in runInTerminalTool.

Metadata

Metadata

Assignees

Labels

chat-terminalThe run in terminal tool in chatdebtCode quality issuesinsiders-releasedPatch has been released in VS Code Insiders

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions