Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/878.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**The .NET bridge no longer drops netcoredbg's last DAP frames** — the TCP-to-stdio bridge ended the proxy's socket on the child's `exit` event, but Node fires `exit` when the process ends and `close` only once its stdio has drained, so an `output`/`exited`/`terminated` frame netcoredbg wrote just before exiting could still be in the pipe when the socket was closed (the same class as #860). The bridge now ends the socket on `close`, stops forwarding proxy bytes to a stdin that has no process behind it, logs an in-flight stdin error instead of dying on it, and says in its stderr when netcoredbg closed and when the proxy's socket errored, so the proxy log explains the next report of this class (#878)
2 changes: 1 addition & 1 deletion packages/adapter-dotnet/docs/dotnet-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ MCP Client → MCP Server → SessionManager → ProxyManager → ProxyWorker

- **DotnetAdapterPolicy** (`packages/shared/src/interfaces/adapter-policy-dotnet.ts`): DAP proxy policy encoding netcoredbg-specific behaviors — DAP sequence, scope naming, variable filtering, stack frame filtering, and executable resolution. The policy has a built-in fallback in `getAdapterSpawnConfig`: when `payload.adapterCommand` is missing (i.e., the bridge command was not supplied by `DotnetDebugAdapter.buildAdapterCommand`), it launches netcoredbg directly in `--server` mode. Note: this direct mode may not work reliably on all platforms due to a known connection bug.

- **netcoredbg-bridge** (`src/utils/netcoredbg-bridge.ts`): TCP-to-stdio bridge that works around a netcoredbg `--server=PORT` bug on Windows. Listens on a TCP port and spawns netcoredbg lazily on first client connection in stdio mode, forwarding DAP messages bidirectionally. The adapter also depends on the bundled `netcoredbg-bridge.js` script (compiled from this source) which is resolved at runtime from multiple candidate paths.
- **netcoredbg-bridge** (`src/utils/netcoredbg-bridge.ts`): TCP-to-stdio bridge, used on all platforms, that works around a netcoredbg `--server=PORT` bug (found on Windows, affecting every platform). Listens on a TCP port and spawns netcoredbg lazily on first client connection in stdio mode, forwarding DAP messages bidirectionally; the socket is ended on the child's `close` (stdio drained), never on `exit`, so netcoredbg's last frames are not lost (#878). The adapter also depends on the bundled `netcoredbg-bridge.js` script (compiled from this source) which is resolved at runtime from multiple candidate paths.

- **dotnet-utils** (`src/utils/dotnet-utils.ts`): Utility functions for netcoredbg discovery, PDB format detection, and Windows PDB to Portable PDB conversion via Pdb2Pdb.

Expand Down
8 changes: 6 additions & 2 deletions packages/adapter-dotnet/src/utils/dotnet-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
* This adapter uses netcoredbg (Samsung, open-source, MIT license).
* Supports .NET Core / .NET 5+ out of the box.
*
* netcoredbg natively supports DAP over TCP via --server=PORT --interpreter=vscode.
* No bridge process is needed.
* netcoredbg is reached through a TCP-to-stdio bridge on every platform
* (`netcoredbg-bridge.ts`): its own `--server=PORT --interpreter=vscode` mode
* drops the TCP connection after the DAP initialize sequence (a bug found on
* Windows that affects all platforms), so the bridge spawns netcoredbg in
* stdio mode and exposes the TCP socket the proxy connects to. See
* docs/dotnet/README.md.
*
* ## PDB Conversion (convertPdbsToTemp)
*
Expand Down
36 changes: 31 additions & 5 deletions packages/adapter-dotnet/src/utils/netcoredbg-bridge-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
*
* Extracted from netcoredbg-bridge.ts so the bridge behaviour can be
* exercised in unit tests with mock spawn and mock sockets.
*
* Lifecycle rule: the socket is ended on the child's `close` event, never on
* `exit` — Node fires `exit` when the process ends and `close` once its stdio
* streams have drained, and netcoredbg's last DAP frames (`output`, `exited`,
* `terminated`) can still be in the stdout pipe at `exit` (issue #878).
*/
import net from 'net';
import { spawn, ChildProcess, type SpawnOptions } from 'child_process';
Expand Down Expand Up @@ -54,10 +59,22 @@ export function createBridge(
windowsHide: true
});

// A proxy request that races the child's exit lands on a dead pipe. The
// `error` listener is what protects that race in production (an EPIPE
// raised by a write already in flight is logged, not thrown — before it
// there was no listener, so it would have crashed the bridge); the
// `childEnded` flag is belt and braces, since Node destroys the child's
// stdin before it emits `exit`.
let childEnded = false;
netcoredbg.stdin?.on('error', (err: NodeJS.ErrnoException) => {
stderrStream.write(`netcoredbg stdin: ${err.message}\n`);
});

// Forward: TCP → netcoredbg stdin
socket.on('data', (data) => {
if (netcoredbg?.stdin?.writable) {
netcoredbg.stdin.write(data);
const stdin = netcoredbg?.stdin;
if (!childEnded && stdin && !stdin.destroyed && stdin.writable) {
stdin.write(data);
}
});

Expand All @@ -78,8 +95,16 @@ export function createBridge(
stderrStream.write(data);
});

// Handle netcoredbg exit
netcoredbg.on('exit', (_code) => {
// `exit` only marks the child gone; the socket is ended on `close`, when
// its stdout has drained — ending it at `exit` closed the proxy's side
// before the last DAP frames were forwarded (issue #878).
netcoredbg.on('exit', () => {
childEnded = true;
});

netcoredbg.on('close', (code, signal) => {
childEnded = true;
stderrStream.write(`netcoredbg closed (code=${code}, signal=${signal})\n`);
if (!socket.destroyed) {
socket.end();
}
Expand All @@ -103,7 +128,8 @@ export function createBridge(
server.close();
});

socket.on('error', () => {
socket.on('error', (err) => {
stderrStream.write(`bridge socket error: ${err.message}\n`);
if (netcoredbg) {
netcoredbg.stdin?.end();
netcoredbg.kill();
Expand Down
76 changes: 74 additions & 2 deletions packages/adapter-dotnet/tests/unit/netcoredbg-bridge-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe('netcoredbg-bridge-core', () => {
expect(mockCp.kill).toHaveBeenCalled();
});

it('cleans up on netcoredbg exit', async () => {
it('cleans up once netcoredbg has closed', async () => {
bridge = createBridge('/usr/bin/netcoredbg', 0, { spawnFn, stderr: stderrStream });
const port = await waitForListening(bridge.server);

Expand All @@ -180,10 +180,82 @@ describe('netcoredbg-bridge-core', () => {
client.on('close', resolve);
});

mockCp.emit('exit', 0);
mockCp.emit('exit', 0, null);
mockCp.emit('close', 0, null);
await ended;
});

it('keeps the socket open across netcoredbg exit until its streams close, then ends it (issue #878)', async () => {
bridge = createBridge('/usr/bin/netcoredbg', 0, { spawnFn, stderr: stderrStream });
const port = await waitForListening(bridge.server);

const client = await connectClient(port);
await tick();

const received: Buffer[] = [];
client.on('data', (d: Buffer) => received.push(d));
const ended = new Promise<void>((resolve) => client.on('end', resolve));

// Node fires `exit` when the process ends and `close` when its stdio has
// drained: the last DAP frame can still be in the stdout pipe at `exit`.
mockCp.emit('exit', 0, null);
const lastFrame = Buffer.from('Content-Length: 2\r\n\r\n{}');
mockCp.stdout.emit('data', lastFrame);
mockCp.emit('close', 0, null);
await ended;

expect(Buffer.concat(received).toString()).toBe(lastFrame.toString());
expect(stderrChunks.join('')).toBe('netcoredbg closed (code=0, signal=null)\n');
});

it('survives the proxy closing first and the child closing afterwards', async () => {
bridge = createBridge('/usr/bin/netcoredbg', 0, { spawnFn, stderr: stderrStream });
const port = await waitForListening(bridge.server);

const client = await connectClient(port);
await tick();

client.destroy();
await tick();
expect(mockCp.kill).toHaveBeenCalled();

// The late exit/close of the killed child must neither throw nor double-end.
mockCp.emit('exit', null, 'SIGTERM');
mockCp.emit('close', null, 'SIGTERM');
await tick();
expect(stderrChunks.join('')).toBe('netcoredbg closed (code=null, signal=SIGTERM)\n');
});

it('stops forwarding socket bytes to netcoredbg stdin once it has exited', async () => {
bridge = createBridge('/usr/bin/netcoredbg', 0, { spawnFn, stderr: stderrStream });
const port = await waitForListening(bridge.server);

const client = await connectClient(port);
await tick();

const stdinWrite = vi.spyOn(mockCp.stdin, 'write');
mockCp.emit('exit', 0, null);
client.write(Buffer.from('Content-Length: 2\r\n\r\n{}'));
await tick();

expect(stdinWrite).not.toHaveBeenCalled();
mockCp.emit('close', 0, null);
});

it('logs a netcoredbg stdin error instead of dying on it', async () => {
bridge = createBridge('/usr/bin/netcoredbg', 0, { spawnFn, stderr: stderrStream });
const port = await waitForListening(bridge.server);

const client = await connectClient(port);
await tick();

mockCp.stdin.emit('error', new Error('EPIPE'));
await tick();

expect(stderrChunks.join('')).toContain('netcoredbg stdin: EPIPE');
client.destroy();
});

it('handles netcoredbg spawn error', async () => {
bridge = createBridge('/usr/bin/netcoredbg', 0, { spawnFn, stderr: stderrStream });
const port = await waitForListening(bridge.server);
Expand Down
Loading