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
12 changes: 6 additions & 6 deletions remote-attach.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

const TMUX_FIELD_RE = /^([A-Za-z0-9._-]{1,64}):(@?\d{1,10}(?:\.%?\d{1,10})?)$/;
const PROBE_SEP = '\u0001';
const DETACH_KEYS = '\x02d'; // Ctrl-B d — tmux default prefix, then detach
const DETACH_GRACE_MS = 150;
const DEFAULT_PROBE_TIMEOUT_MS = 15000;
const DEFAULT_STATUS_LINES = 1;
const NO_TMUX_ENV_EXIT_CODE = 3;
Expand Down Expand Up @@ -220,12 +218,15 @@ function createTmuxAttachAdapter(opts = {}) {
raw.onExit(() => { alive = false; });

let detaching = false;
// Ending the local ssh client is what detaches: the remote tmux client
// loses its pty and tmux drops it, leaving the session running. Sending a
// prefix keystroke instead would assume this host's prefix, and land as
// literal text in the remote session on any host that remapped it.
// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach")
function detach() {
if (detaching || !alive) return;
detaching = true;
try { raw.write(DETACH_KEYS); } catch {}
// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach")
setTimeout(() => { try { raw.kill(); } catch {} }, DETACH_GRACE_MS);
try { raw.kill(); } catch {}
}

const ptyProcess = {
Expand Down Expand Up @@ -264,5 +265,4 @@ module.exports = {
parseDiscoveryProbeOutput,
buildProbeCommand,
buildAttachCommand,
DETACH_KEYS,
};
17 changes: 9 additions & 8 deletions test/remote-attach.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,8 @@ test('attach() refuses a descriptor with no readable pid, before any ssh call',
});

// Property 3 -- the returned ptyProcess is pilotable without any real local
// node-pty: writes reach the underlying process, and kill() detaches cleanly
// (Ctrl-B d) before ending the local ssh client, rather than killing outright.
// node-pty: writes reach the underlying process, and kill() detaches by
// ending the local ssh client, sending nothing to the remote session.
test('the returned ptyProcess pilots the fake remote pty through write() and kill()', async () => {
const raw = fakeRawPty();
const adapter = makeAdapter({ probeStdout: '200x50' + PROBE_SEP + 'status on', rawPtyFactory: () => raw.pty });
Expand All @@ -222,12 +222,13 @@ test('the returned ptyProcess pilots the fake remote pty through write() and kil
assert.equal(ptyProcess.pid, 4242);

ptyProcess.kill();
// Detach sends Ctrl-B d before ending the local client -- see DETACH_KEYS.
assert.equal(raw.writes[raw.writes.length - 1], '\x02d', 'kill() must send the tmux detach sequence, not just end the process');
assert.equal(raw.killedCount(), 0, 'the local client must not be ended immediately -- see the detach grace period');

await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(raw.killedCount(), 1, 'the local client must be ended once the detach keystroke has had time to land');
// Detaching means ending the local ssh client and nothing else: any prefix
// keystroke would assume this host's tmux prefix and land as literal text
// in the remote session on a host that remapped it. Measured on the live
// host: killing the client alone takes the attached client count back to 0
// and leaves the session running.
assert.deepEqual(raw.writes, ['echo hi\n'], 'kill() must not send any keystroke to the remote session');
assert.equal(raw.killedCount(), 1, 'kill() must end the local ssh client');
assert.equal(ptyProcess.isAlive(), false);
});

Expand Down
Loading