Skip to content

Commit d61fe3c

Browse files
authored
Make result pipe drain fully event-driven (drop timeout fallback)
1 parent fa8111a commit d61fe3c

2 files changed

Lines changed: 24 additions & 34 deletions

File tree

src/client/testing/testController/common/utils.ts

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -89,45 +89,28 @@ export async function startRunResultNamedPipe(
8989
traceVerbose('Starting Test Result named pipe');
9090
const pipeName: string = generateRandomPipeName('python-test-results');
9191

92+
// NOTE: `cancellationToken` is intentionally only forwarded to `createReaderPipe`
93+
// (which uses it to cancel pipe *creation*). Once the reader is connected we do
94+
// not wire a cancellation handler here. Disposing the reader on cancellation
95+
// would close the socket while data was still buffered in the kernel pipe, and
96+
// any results not yet delivered to the `reader.listen` callback would be lost.
97+
// This is exactly the regression seen in the debug path, where cancellation
98+
// fires the moment the debug session terminates.
99+
//
100+
// Instead, disposal is fully event-driven: when the subprocess closes its end
101+
// of the pipe (either by exiting normally or by being killed via the caller's
102+
// own cancellation handling), the OS delivers all remaining buffered bytes and
103+
// then EOF, which fires `reader.onClose` below and triggers dispose.
92104
const reader = await createReaderPipe(pipeName, cancellationToken);
93105
traceVerbose(`Test Results named pipe ${pipeName} connected`);
94106
let disposables: Disposable[] = [];
95-
let disposed = false;
96107
const disposable = new Disposable(() => {
97108
traceVerbose(`Test Results named pipe ${pipeName} disposed`);
98-
disposed = true;
99109
disposables.forEach((d) => d.dispose());
100110
disposables = [];
101111
deferredTillServerClose.resolve();
102112
});
103113

104-
if (cancellationToken) {
105-
disposables.push(
106-
cancellationToken?.onCancellationRequested(() => {
107-
traceLog(
108-
`Test Result named pipe ${pipeName} cancelled; draining buffered data before dispose`,
109-
);
110-
// Do NOT dispose the reader immediately. In the debug path, cancellation
111-
// fires as soon as the debug session terminates, but the result pipe may
112-
// still have buffered messages that have not been delivered to the
113-
// `reader.listen` callback yet. Disposing now would close the reader and
114-
// drop those pending results.
115-
//
116-
// The reader's `onClose` event (registered below) will fire once the
117-
// subprocess closes its end of the pipe and all buffered data has been
118-
// drained, and that handler will dispose. Use a safety timeout to force
119-
// disposal in case the pipe never closes naturally (e.g. subprocess hang).
120-
setTimeout(() => {
121-
if (!disposed) {
122-
traceVerbose(
123-
`Test Result named pipe ${pipeName} drain timeout, forcing dispose`,
124-
);
125-
disposable.dispose();
126-
}
127-
}, 5000);
128-
}),
129-
);
130-
}
131114
disposables.push(
132115
reader,
133116
reader.listen((data: Message) => {
@@ -136,9 +119,10 @@ export async function startRunResultNamedPipe(
136119
dataReceivedCallback((data as ExecutionResultMessage).params as ExecutionTestPayload);
137120
}),
138121
reader.onClose(() => {
139-
// this is called once the server close, once per run instance
122+
// Fires once the subprocess has closed its end of the pipe and the OS
123+
// has delivered all buffered data. This is the only path that disposes
124+
// the reader, so no results can be dropped due to a premature close.
140125
traceVerbose(`Test Result named pipe ${pipeName} closed. Disposing of listener/s.`);
141-
// dispose of all data listeners and cancelation listeners
142126
disposable.dispose();
143127
}),
144128
reader.onError((error) => {

src/client/testing/testController/unittest/testExecutionAdapter.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,15 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
7070
cSource.token, // token to cancel
7171
);
7272
runInstance.token.onCancellationRequested(() => {
73-
console.log(`Test run cancelled, resolving 'till TillAllServerClose' deferred for ${uri.fsPath}.`);
74-
// if canceled, stop listening for results
75-
deferredTillServerClose.resolve();
73+
console.log(`Test run cancelled for ${uri.fsPath}; waiting for result pipe to drain.`);
74+
// Do NOT resolve `deferredTillServerClose` here. Doing so would release
75+
// the `await deferredTillServerClose.promise` in `finally` before the
76+
// named pipe has finished draining any results buffered by the OS,
77+
// and those results would be dropped. The pipe will drain on its own
78+
// once the subprocess closes its end (which happens when the caller
79+
// kills the subprocess below or when the debug session terminates),
80+
// at which point `reader.onClose` in `startRunResultNamedPipe` fires
81+
// and resolves the deferred.
7682
});
7783
try {
7884
await this.runTestsNew(

0 commit comments

Comments
 (0)