Skip to content

Commit 698e8eb

Browse files
fix: frame intentional machine detach outcomes
1 parent d5fabc3 commit 698e8eb

5 files changed

Lines changed: 181 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939

4040
- `pty attach --attach-stream-fd-v1 <fd> <ref>` keeps stdin/stdout as the
4141
controlling terminal while writing ordered, existing-protocol `GEOMETRY`,
42-
`SCREEN`, `DATA`, and `EXIT` packets to a dedicated inherited descriptor.
42+
`SCREEN`, and `DATA` packets plus a terminal `EXIT` or `DETACH` outcome to a
43+
dedicated inherited descriptor. Intentional local detach is framed and
44+
flushed even when it occurs before the initial daemon baseline, so consumers
45+
can distinguish it from a truncated stream.
4346
The descriptor remains caller-owned and must be closed by the caller for
4447
consumers to observe EOF. Invalid descriptors, write failures, and daemons
4548
that do not provide the v1 geometry-first contract fail clearly on stderr.

docs/client.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,14 +336,19 @@ Interactive attach with bidirectional I/O. Takes over stdin/stdout. Ctrl+\ to de
336336
Set `attachStreamFdV1` to a writable inherited descriptor (3 or greater) for
337337
machine mode. stdin and stdout remain the controlling terminal for input and
338338
resize events, but terminal output is written only to that descriptor using the
339-
existing protocol framing. Version 1 emits ordered `GEOMETRY`, `SCREEN`, `DATA`,
340-
and `EXIT` packets. Each initial attach or reconnect starts with `GEOMETRY`; a
339+
existing protocol framing. Version 1 emits ordered `GEOMETRY`, `SCREEN`, and
340+
`DATA` packets followed by one terminal outcome: `EXIT` when the session process
341+
ends or `DETACH` when the local user intentionally detaches. `DETACH` may be the
342+
first packet when the user detaches before the daemon supplies its initial
343+
baseline. Each initial attach or reconnect otherwise starts with `GEOMETRY`; a
341344
daemon that sends terminal data first is rejected as unsupported.
342345

343346
The descriptor remains caller-owned. `attach()` flushes its writer but does not
344347
close the descriptor, so a consumer sees EOF only when the caller closes its
345-
copy (or the process exits). Descriptor errors fail the attach and are reported
346-
on stderr; stderr text is never written into the framed stream.
348+
copy (or the process exits). A clean EOF follows a framed `EXIT` or `DETACH`;
349+
EOF without either outcome is a truncated stream. Descriptor errors fail the
350+
attach and are reported on stderr; stderr text is never written into the framed
351+
stream.
347352

348353
### `peek(options: PeekOptions): void`
349354

src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ Flags:
120120
the session's name/id ON THE REMOTE
121121
--attach-stream-fd-v1 <fd>
122122
Machine mode for a running session. Write ordered framed
123-
GEOMETRY, SCREEN, DATA, and EXIT events to inherited fd
124-
(>= 3); keep stdin/stdout as the controlling terminal
123+
GEOMETRY, SCREEN, DATA, and terminal EXIT or DETACH outcome
124+
to inherited fd (>= 3); keep stdin/stdout controlling TTY
125125
126126
Examples:
127127
pty attach myserver

src/client.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,27 @@ export function attach(options: AttachOptions): void {
500500
completeExit(code);
501501
}
502502
}
503+
function finishDetach(): void {
504+
if (exitHandled) return;
505+
exitHandled = true;
506+
detaching = true;
507+
const completeDetach = () => {
508+
if (exitCompleted) return;
509+
exitCompleted = true;
510+
options.onDetach?.();
511+
};
512+
if (attachStream && !attachStream.destroyed && !attachStream.writableEnded) {
513+
attachStream.write(encodeDetach());
514+
try { socket.write(encodeDetach()); } catch {}
515+
cleanExit();
516+
attachStream.end(completeDetach);
517+
} else {
518+
try { socket.write(encodeDetach()); } catch {}
519+
cleanExit();
520+
stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n");
521+
completeDetach();
522+
}
523+
}
503524

504525
attachStream?.on("error", (error) => {
505526
console.error(`pty attach: machine stream descriptor ${attachStreamFd} failed: ${error.message}`);
@@ -536,15 +557,7 @@ export function attach(options: AttachOptions): void {
536557
lastDetachKeyTime = now;
537558
setTimeout(() => {
538559
if (lastDetachKeyTime === now) {
539-
detaching = true;
540-
try { socket.write(encodeDetach()); } catch {}
541-
cleanExit();
542-
if (attachStream) {
543-
attachStream.end(() => options.onDetach?.());
544-
} else {
545-
stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n");
546-
options.onDetach?.();
547-
}
560+
finishDetach();
548561
}
549562
}, DOUBLE_TAP_MS);
550563
}

tests/attach-stream.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,150 @@ describe("pty attach --attach-stream-fd-v1", () => {
180180
expect(packets.at(-1)?.type).toBe(MessageType.EXIT);
181181
}, 15_000);
182182

183+
it("frames an intentional local detach before the shipped bin launcher closes fd 3", async () => {
184+
const root = fs.mkdtempSync(path.join(testRoot, "launcher-detach-"));
185+
const name = `launcher-detach-${process.pid}`;
186+
const launcherEnv: NodeJS.ProcessEnv = {
187+
...process.env,
188+
PTY_ROOT: root,
189+
PTY_ROOT_LEGACY_SILENT: "1",
190+
};
191+
delete launcherEnv.PTY_SESSION;
192+
delete launcherEnv.PTY_SERVER_CONFIG;
193+
const created = spawnSync(
194+
nodeBin,
195+
[cliPath, "run", "-d", "--id", name, "--", "sh", "-c", "printf DETACH_READY; sleep 300"],
196+
{ env: launcherEnv, encoding: "utf8" },
197+
);
198+
expect(created.status, created.stderr).toBe(0);
199+
200+
const child = spawn(
201+
nodeBin,
202+
[binPath, "attach", "--attach-stream-fd-v1", "3", name],
203+
{ env: launcherEnv, stdio: ["pipe", "pipe", "pipe", "pipe"] },
204+
);
205+
const stdout = collect(child.stdout);
206+
const stderr = collect(child.stderr);
207+
const streamChunks: Buffer[] = [];
208+
const reader = new PacketReader();
209+
let requestedDetach = false;
210+
(child.stdio[3] as NodeJS.ReadableStream).on("data", (chunk) => {
211+
const data = Buffer.from(chunk);
212+
streamChunks.push(data);
213+
for (const packet of reader.feed(data)) {
214+
if (packet.type === MessageType.SCREEN && !requestedDetach) {
215+
requestedDetach = true;
216+
child.stdin.write(Buffer.from([0x1c]));
217+
}
218+
}
219+
});
220+
221+
const status = await new Promise<number | null>((resolve, reject) => {
222+
const timer = setTimeout(() => reject(new Error("bin launcher detach timed out")), 10_000);
223+
child.once("exit", (code) => {
224+
clearTimeout(timer);
225+
resolve(code);
226+
});
227+
});
228+
spawnSync(nodeBin, [cliPath, "kill", name], { env: launcherEnv, encoding: "utf8" });
229+
230+
expect(status).toBe(0);
231+
expect(await stdout).toEqual(Buffer.alloc(0));
232+
expect(await stderr).toEqual(Buffer.alloc(0));
233+
const packets = new PacketReader().feed(Buffer.concat(streamChunks));
234+
expect(packets[0].type).toBe(MessageType.GEOMETRY);
235+
expect(packets[1].type).toBe(MessageType.SCREEN);
236+
expect(packets[1].payload.toString()).toContain("DETACH_READY");
237+
expect(packets.at(-1)?.type).toBe(MessageType.DETACH);
238+
expect(packets.some((packet) => packet.type === MessageType.EXIT)).toBe(false);
239+
}, 15_000);
240+
241+
it("frames an intentional local detach before the initial daemon baseline", async () => {
242+
const { server, port } = await listen();
243+
const attached = new Promise<void>((resolve) => {
244+
server.once("connection", (socket) => socket.once("data", () => resolve()));
245+
});
246+
const child = spawn(nodeBin, ["--input-type=module", "-e", attachScript(port)], {
247+
stdio: ["pipe", "pipe", "pipe", "pipe"],
248+
});
249+
const stdout = collect(child.stdout);
250+
const stderr = collect(child.stderr);
251+
const stream = collect(child.stdio[3] as NodeJS.ReadableStream);
252+
await attached;
253+
child.stdin.write(Buffer.from([0x1c]));
254+
const status = await new Promise<number | null>((resolve, reject) => {
255+
const timer = setTimeout(() => reject(new Error("pre-baseline detach timed out")), 5_000);
256+
child.once("exit", (code) => {
257+
clearTimeout(timer);
258+
resolve(code);
259+
});
260+
});
261+
server.close();
262+
263+
expect(status).toBe(0);
264+
expect(await stdout).toEqual(Buffer.alloc(0));
265+
expect(await stderr).toEqual(Buffer.alloc(0));
266+
expect(new PacketReader().feed(await stream).map((packet) => packet.type)).toEqual([
267+
MessageType.DETACH,
268+
]);
269+
});
270+
271+
it("does not frame DETACH when EXIT wins the pending detach-key window", async () => {
272+
const { server, port } = await listen();
273+
let daemonSocket: net.Socket | undefined;
274+
server.once("connection", (socket) => {
275+
daemonSocket = socket;
276+
socket.once("data", () => socket.write(Buffer.concat([
277+
encodeGeometry(24, 80),
278+
encodeScreen("exit wins"),
279+
])));
280+
});
281+
const script = `
282+
import net from "node:net";
283+
import { attach } from ${JSON.stringify(clientUrl)};
284+
const socket = net.createConnection({ host: "127.0.0.1", port: ${port} });
285+
socket.once("connect", () => attach({
286+
name: "fixture",
287+
socket,
288+
attachStreamFdV1: 3,
289+
onExit: (code) => setTimeout(() => process.exit(code), 600),
290+
onDetach: () => process.exit(42),
291+
}));
292+
`;
293+
const child = spawn(nodeBin, ["--input-type=module", "-e", script], {
294+
stdio: ["pipe", "pipe", "pipe", "pipe"],
295+
});
296+
const stdout = collect(child.stdout);
297+
const stderr = collect(child.stderr);
298+
const chunks: Buffer[] = [];
299+
const reader = new PacketReader();
300+
(child.stdio[3] as NodeJS.ReadableStream).on("data", (chunk) => {
301+
const data = Buffer.from(chunk);
302+
chunks.push(data);
303+
if (reader.feed(data).some((packet) => packet.type === MessageType.SCREEN)) {
304+
child.stdin.write(Buffer.from([0x1c]));
305+
setTimeout(() => daemonSocket?.end(encodeExit(0)), 50);
306+
}
307+
});
308+
const status = await new Promise<number | null>((resolve, reject) => {
309+
const timer = setTimeout(() => reject(new Error("exit/detach race timed out")), 5_000);
310+
child.once("exit", (code) => {
311+
clearTimeout(timer);
312+
resolve(code);
313+
});
314+
});
315+
server.close();
316+
317+
expect(status).toBe(0);
318+
expect(await stdout).toEqual(Buffer.alloc(0));
319+
expect(await stderr).toEqual(Buffer.alloc(0));
320+
expect(new PacketReader().feed(Buffer.concat(chunks)).map((packet) => packet.type)).toEqual([
321+
MessageType.GEOMETRY,
322+
MessageType.SCREEN,
323+
MessageType.EXIT,
324+
]);
325+
});
326+
183327
it("reframes fragmented and coalesced daemon packets in order without stdout output", async () => {
184328
const geometry = encodeGeometry(31, 97);
185329
const screen = encodeScreen("\x1b[31mred\x1b[0m");

0 commit comments

Comments
 (0)