Enable per-request cancellation (#12371)

enable -per-request cancellation

* restore request for deferred calls

* add tests

* introduce MultistepOperation

* (test) subsequent request cancels the preceding one
This commit is contained in:
Vladimir Matveev
2017-02-14 13:18:42 -08:00
committed by GitHub
parent 1f484a9a03
commit 81f4e38643
9 changed files with 516 additions and 88 deletions

View File

@@ -1,14 +1,24 @@
/// <reference types="node" />
// TODO: extract services types
interface HostCancellationToken {
isCancellationRequested(): boolean;
}
/// <reference types="node"/>
import fs = require("fs");
function createCancellationToken(args: string[]): HostCancellationToken {
interface ServerCancellationToken {
isCancellationRequested(): boolean;
setRequest(requestId: number): void;
resetRequest(requestId: number): void;
}
function pipeExists(name: string): boolean {
try {
fs.statSync(name);
return true;
}
catch (e) {
return false;
}
}
function createCancellationToken(args: string[]): ServerCancellationToken {
let cancellationPipeName: string;
for (let i = 0; i < args.length - 1; i++) {
if (args[i] === "--cancellationPipeName") {
@@ -17,18 +27,44 @@ function createCancellationToken(args: string[]): HostCancellationToken {
}
}
if (!cancellationPipeName) {
return { isCancellationRequested: () => false };
return {
isCancellationRequested: () => false,
setRequest: (_requestId: number): void => void 0,
resetRequest: (_requestId: number): void => void 0
};
}
return {
isCancellationRequested() {
try {
fs.statSync(cancellationPipeName);
return true;
}
catch (e) {
return false;
}
// cancellationPipeName is a string without '*' inside that can optionally end with '*'
// when client wants to signal cancellation it should create a named pipe with name=<cancellationPipeName>
// server will synchronously check the presence of the pipe and treat its existance as indicator that current request should be canceled.
// in case if client prefers to use more fine-grained schema than one name for all request it can add '*' to the end of cancelellationPipeName.
// in this case pipe name will be build dynamically as <cancellationPipeName><request_seq>.
if (cancellationPipeName.charAt(cancellationPipeName.length - 1) === "*") {
const namePrefix = cancellationPipeName.slice(0, -1);
if (namePrefix.length === 0 || namePrefix.indexOf("*") >= 0) {
throw new Error("Invalid name for template cancellation pipe: it should have length greater than 2 characters and contain only one '*'.");
}
};
let perRequestPipeName: string;
let currentRequestId: number;
return {
isCancellationRequested: () => perRequestPipeName !== undefined && pipeExists(perRequestPipeName),
setRequest(requestId: number) {
currentRequestId = currentRequestId;
perRequestPipeName = namePrefix + requestId;
},
resetRequest(requestId: number) {
if (currentRequestId !== requestId) {
throw new Error(`Mismatched request id, expected ${currentRequestId}, actual ${requestId}`);
}
perRequestPipeName = undefined;
}
};
}
else {
return {
isCancellationRequested: () => pipeExists(cancellationPipeName),
setRequest: (_requestId: number): void => void 0,
resetRequest: (_requestId: number): void => void 0
};
}
}
export = createCancellationToken;

View File

@@ -13,6 +13,25 @@ namespace ts.server {
findInComments: boolean;
}
/* @internal */
export function extractMessage(message: string) {
// Read the content length
const contentLengthPrefix = "Content-Length: ";
const lines = message.split(/\r?\n/);
Debug.assert(lines.length >= 2, "Malformed response: Expected 3 lines in the response.");
const contentLengthText = lines[0];
Debug.assert(contentLengthText.indexOf(contentLengthPrefix) === 0, "Malformed response: Response text did not contain content-length header.");
const contentLength = parseInt(contentLengthText.substring(contentLengthPrefix.length));
// Read the body
const responseBody = lines[2];
// Verify content length
Debug.assert(responseBody.length + 1 === contentLength, "Malformed response: Content length did not match the response's body length.");
return responseBody;
}
export class SessionClient implements LanguageService {
private sequence: number = 0;
private lineMaps: ts.Map<number[]> = ts.createMap<number[]>();
@@ -84,7 +103,7 @@ namespace ts.server {
while (!foundResponseMessage) {
lastMessage = this.messages.shift();
Debug.assert(!!lastMessage, "Did not receive any responses.");
const responseBody = processMessage(lastMessage);
const responseBody = extractMessage(lastMessage);
try {
response = JSON.parse(responseBody);
// the server may emit events before emitting the response. We
@@ -109,24 +128,6 @@ namespace ts.server {
Debug.assert(!!response.body, "Malformed response: Unexpected empty response body.");
return response;
function processMessage(message: string) {
// Read the content length
const contentLengthPrefix = "Content-Length: ";
const lines = message.split("\r\n");
Debug.assert(lines.length >= 2, "Malformed response: Expected 3 lines in the response.");
const contentLengthText = lines[0];
Debug.assert(contentLengthText.indexOf(contentLengthPrefix) === 0, "Malformed response: Response text did not contain content-length header.");
const contentLength = parseInt(contentLengthText.substring(contentLengthPrefix.length));
// Read the body
const responseBody = lines[2];
// Verify content length
Debug.assert(responseBody.length + 1 === contentLength, "Malformed response: Content length did not match the response's body length.");
return responseBody;
}
}
openFile(fileName: string, content?: string, scriptKindName?: "TS" | "JS" | "TSX" | "JSX"): void {

View File

@@ -1766,6 +1766,20 @@ namespace ts.server.protocol {
arguments: GeterrRequestArgs;
}
export type RequestCompletedEventName = "requestCompleted";
/**
* Event that is sent when server have finished processing request with specified id.
*/
export interface RequestCompletedEvent extends Event {
event: RequestCompletedEventName;
body: RequestCompletedEventBody;
}
export interface RequestCompletedEventBody {
request_seq: number;
}
/**
* Item of diagnostic information found in a DiagnosticEvent message.
*/

View File

@@ -354,7 +354,7 @@ namespace ts.server {
class IOSession extends Session {
constructor(
host: ServerHost,
cancellationToken: HostCancellationToken,
cancellationToken: ServerCancellationToken,
installerEventPort: number,
canUseEvents: boolean,
useSingleInferredProject: boolean,
@@ -593,15 +593,13 @@ namespace ts.server {
sys.gc = () => global.gc();
}
let cancellationToken: HostCancellationToken;
let cancellationToken: ServerCancellationToken;
try {
const factory = require("./cancellationToken");
cancellationToken = factory(sys.args);
}
catch (e) {
cancellationToken = {
isCancellationRequested: () => false
};
cancellationToken = nullCancellationToken;
};
let eventPort: number;

View File

@@ -8,6 +8,17 @@ namespace ts.server {
stack?: string;
}
export interface ServerCancellationToken extends HostCancellationToken {
setRequest(requestId: number): void;
resetRequest(requestId: number): void;
}
export const nullCancellationToken: ServerCancellationToken = {
isCancellationRequested: () => false,
setRequest: () => void 0,
resetRequest: () => void 0
};
function hrTimeToMilliseconds(time: number[]): number {
const seconds = time[0];
const nanoseconds = time[1];
@@ -193,18 +204,134 @@ namespace ts.server {
return `Content-Length: ${1 + len}\r\n\r\n${json}${newLine}`;
}
/**
* Allows to schedule next step in multistep operation
*/
interface NextStep {
immediate(action: () => void): void;
delay(ms: number, action: () => void): void;
}
/**
* External capabilities used by multistep operation
*/
interface MultistepOperationHost {
getCurrentRequestId(): number;
sendRequestCompletedEvent(requestId: number): void;
getServerHost(): ServerHost;
isCancellationRequested(): boolean;
executeWithRequestId(requestId: number, action: () => void): void;
logError(error: Error, message: string): void;
}
/**
* Represents operation that can schedule its next step to be executed later.
* Scheduling is done via instance of NextStep. If on current step subsequent step was not scheduled - operation is assumed to be completed.
*/
class MultistepOperation {
private requestId: number;
private timerHandle: any;
private immediateId: any;
private completed = true;
private readonly next: NextStep;
constructor(private readonly operationHost: MultistepOperationHost) {
this.next = {
immediate: action => this.immediate(action),
delay: (ms, action) => this.delay(ms, action)
}
}
public startNew(action: (next: NextStep) => void) {
this.complete();
this.requestId = this.operationHost.getCurrentRequestId();
this.completed = false;
this.executeAction(action);
}
private complete() {
if (!this.completed) {
if (this.requestId) {
this.operationHost.sendRequestCompletedEvent(this.requestId);
}
this.completed = true;
}
this.setTimerHandle(undefined);
this.setImmediateId(undefined);
}
private immediate(action: () => void) {
const requestId = this.requestId;
Debug.assert(requestId === this.operationHost.getCurrentRequestId(), "immediate: incorrect request id")
this.setImmediateId(this.operationHost.getServerHost().setImmediate(() => {
this.immediateId = undefined;
this.operationHost.executeWithRequestId(requestId, () => this.executeAction(action));
}));
}
private delay(ms: number, action: () => void) {
const requestId = this.requestId;
Debug.assert(requestId === this.operationHost.getCurrentRequestId(), "delay: incorrect request id")
this.setTimerHandle(this.operationHost.getServerHost().setTimeout(() => {
this.timerHandle = undefined;
this.operationHost.executeWithRequestId(requestId, () => this.executeAction(action));
}, ms));
}
private executeAction(action: (next: NextStep) => void) {
let stop = false;
try {
if (this.operationHost.isCancellationRequested()) {
stop = true;
}
else {
action(this.next);
}
}
catch (e) {
stop = true;
// ignore cancellation request
if (!(e instanceof OperationCanceledException)) {
this.operationHost.logError(e, `delayed processing of request ${this.requestId}`);
}
}
if (stop || !this.hasPendingWork()) {
this.complete();
}
}
private setTimerHandle(timerHandle: any) {;
if (this.timerHandle !== undefined) {
this.operationHost.getServerHost().clearTimeout(this.timerHandle);
}
this.timerHandle = timerHandle;
}
private setImmediateId(immediateId: number) {
if (this.immediateId !== undefined) {
this.operationHost.getServerHost().clearImmediate(this.immediateId);
}
this.immediateId = immediateId;
}
private hasPendingWork() {
return !!this.timerHandle || !!this.immediateId;
}
}
export class Session implements EventSender {
private readonly gcTimer: GcTimer;
protected projectService: ProjectService;
private errorTimer: any; /*NodeJS.Timer | number*/
private immediateId: any;
private changeSeq = 0;
private currentRequestId: number;
private errorCheck: MultistepOperation;
private eventHander: ProjectServiceEventHandler;
constructor(
private host: ServerHost,
cancellationToken: HostCancellationToken,
private readonly cancellationToken: ServerCancellationToken,
useSingleInferredProject: boolean,
protected readonly typingsInstaller: ITypingsInstaller,
private byteLength: (buf: string, encoding?: string) => number,
@@ -217,17 +344,35 @@ namespace ts.server {
? eventHandler || (event => this.defaultEventHandler(event))
: undefined;
const multistepOperationHost: MultistepOperationHost = {
executeWithRequestId: (requestId, action) => this.executeWithRequestId(requestId, action),
getCurrentRequestId: () => this.currentRequestId,
getServerHost: () => this.host,
logError: (err, cmd) => this.logError(err, cmd),
sendRequestCompletedEvent: requestId => this.sendRequestCompletedEvent(requestId),
isCancellationRequested: () => cancellationToken.isCancellationRequested()
}
this.errorCheck = new MultistepOperation(multistepOperationHost);
this.projectService = new ProjectService(host, logger, cancellationToken, useSingleInferredProject, typingsInstaller, this.eventHander);
this.gcTimer = new GcTimer(host, /*delay*/ 7000, logger);
}
private sendRequestCompletedEvent(requestId: number): void {
const event: protocol.RequestCompletedEvent = {
seq: 0,
type: "event",
event: "requestCompleted",
body: { request_seq: requestId }
};
this.send(event);
}
private defaultEventHandler(event: ProjectServiceEvent) {
switch (event.eventName) {
case ContextEvent:
const { project, fileName } = event.data;
this.projectService.logger.info(`got context event, updating diagnostics for ${fileName}`);
this.updateErrorCheck([{ fileName, project }], this.changeSeq,
(n) => n === this.changeSeq, 100);
this.errorCheck.startNew(next => this.updateErrorCheck(next, [{ fileName, project }], this.changeSeq, (n) => n === this.changeSeq, 100));
break;
case ConfigFileDiagEvent:
const { triggerFile, configFileName, diagnostics } = event.data;
@@ -284,7 +429,7 @@ namespace ts.server {
seq: 0,
type: "event",
event: eventName,
body: info,
body: info
};
this.send(ev);
}
@@ -342,18 +487,11 @@ namespace ts.server {
}, ms);
}
private updateErrorCheck(checkList: PendingErrorCheck[], seq: number,
matchSeq: (seq: number) => boolean, ms = 1500, followMs = 200, requireOpen = true) {
private updateErrorCheck(next: NextStep, checkList: PendingErrorCheck[], seq: number, matchSeq: (seq: number) => boolean, ms = 1500, followMs = 200, requireOpen = true) {
if (followMs > ms) {
followMs = ms;
}
if (this.errorTimer) {
this.host.clearTimeout(this.errorTimer);
}
if (this.immediateId) {
this.host.clearImmediate(this.immediateId);
this.immediateId = undefined;
}
let index = 0;
const checkOne = () => {
if (matchSeq(seq)) {
@@ -361,21 +499,18 @@ namespace ts.server {
index++;
if (checkSpec.project.containsFile(checkSpec.fileName, requireOpen)) {
this.syntacticCheck(checkSpec.fileName, checkSpec.project);
this.immediateId = this.host.setImmediate(() => {
next.immediate(() => {
this.semanticCheck(checkSpec.fileName, checkSpec.project);
this.immediateId = undefined;
if (checkList.length > index) {
this.errorTimer = this.host.setTimeout(checkOne, followMs);
}
else {
this.errorTimer = undefined;
next.delay(followMs, checkOne);
}
});
}
}
};
if ((checkList.length > index) && (matchSeq(seq))) {
this.errorTimer = this.host.setTimeout(checkOne, ms);
next.delay(ms, checkOne);
}
}
@@ -1087,7 +1222,7 @@ namespace ts.server {
}
}
private getDiagnostics(delay: number, fileNames: string[]) {
private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void {
const checkList = fileNames.reduce((accum: PendingErrorCheck[], uncheckedFileName: string) => {
const fileName = toNormalizedPath(uncheckedFileName);
const project = this.projectService.getDefaultProjectForFile(fileName, /*refreshInferredProjects*/ true);
@@ -1098,7 +1233,7 @@ namespace ts.server {
}, []);
if (checkList.length > 0) {
this.updateErrorCheck(checkList, this.changeSeq, (n) => n === this.changeSeq, delay);
this.updateErrorCheck(next, checkList, this.changeSeq, (n) => n === this.changeSeq, delay);
}
}
@@ -1335,7 +1470,7 @@ namespace ts.server {
: spans;
}
getDiagnosticsForProject(delay: number, fileName: string) {
private getDiagnosticsForProject(next: NextStep, delay: number, fileName: string): void {
const { fileNames, languageServiceDisabled } = this.getProjectInfoWorker(fileName, /*projectFileName*/ undefined, /*needFileNameList*/ true);
if (languageServiceDisabled) {
return;
@@ -1373,7 +1508,7 @@ namespace ts.server {
const checkList = fileNamesInProject.map(fileName => ({ fileName, project }));
// Project level error analysis runs on background files too, therefore
// doesn't require the file to be opened
this.updateErrorCheck(checkList, this.changeSeq, (n) => n == this.changeSeq, delay, 200, /*requireOpen*/ false);
this.updateErrorCheck(next, checkList, this.changeSeq, (n) => n == this.changeSeq, delay, 200, /*requireOpen*/ false);
}
}
@@ -1550,13 +1685,13 @@ namespace ts.server {
[CommandNames.SyntacticDiagnosticsSync]: (request: protocol.SyntacticDiagnosticsSyncRequest) => {
return this.requiredResponse(this.getSyntacticDiagnosticsSync(request.arguments));
},
[CommandNames.Geterr]: (request: protocol.Request) => {
const geterrArgs = <protocol.GeterrRequestArgs>request.arguments;
return { response: this.getDiagnostics(geterrArgs.delay, geterrArgs.files), responseRequired: false };
[CommandNames.Geterr]: (request: protocol.GeterrRequest) => {
this.errorCheck.startNew(next => this.getDiagnostics(next, request.arguments.delay, request.arguments.files));
return this.notRequired();
},
[CommandNames.GeterrForProject]: (request: protocol.Request) => {
const { file, delay } = <protocol.GeterrForProjectRequestArgs>request.arguments;
return { response: this.getDiagnosticsForProject(delay, file), responseRequired: false };
[CommandNames.GeterrForProject]: (request: protocol.GeterrForProjectRequest) => {
this.errorCheck.startNew(next => this.getDiagnosticsForProject(next, request.arguments.delay, request.arguments.file));
return this.notRequired();
},
[CommandNames.Change]: (request: protocol.ChangeRequest) => {
this.change(request.arguments);
@@ -1643,10 +1778,32 @@ namespace ts.server {
this.handlers.set(command, handler);
}
private setCurrentRequest(requestId: number): void {
Debug.assert(this.currentRequestId === undefined);
this.currentRequestId = requestId;
this.cancellationToken.setRequest(requestId);
}
private resetCurrentRequest(requestId: number): void {
Debug.assert(this.currentRequestId === requestId);
this.currentRequestId = undefined;
this.cancellationToken.resetRequest(requestId);
}
public executeWithRequestId<T>(requestId: number, f: () => T) {
try {
this.setCurrentRequest(requestId);
return f();
}
finally {
this.resetCurrentRequest(requestId);
}
}
public executeCommand(request: protocol.Request): { response?: any, responseRequired?: boolean } {
const handler = this.handlers.get(request.command);
if (handler) {
return handler(request);
return this.executeWithRequestId(request.seq, () => handler(request));
}
else {
this.logger.msg(`Unrecognized JSON command: ${JSON.stringify(request)}`, Msg.Err);