Skip to content

Commit 5e389ce

Browse files
committed
Make control sessions work on the reattach path too
et reaches a live session two ways: bootstrapping a new one over SSH, and reattaching to one already saved under --name. Only the first ran the control listener, so a control session whose client had died was reattached and then immediately killed: the reattach path builds a terminal console and calls run() with noexit unset, which appends '; exit'. Hoist console creation and control-socket setup above the branch, and give both paths one driver. A control session now behaves the same however it got there. Move the live control files (socket, session-end note) to ~/.et/control rather than sharing ~/.et/sessions with the saved records. Sharing one flat directory reads well until a name collides: a session may legally be named 'foo.sock', which is exactly the path session 'foo' wants for its socket, and a listing cannot tell the two apart without reserving suffixes from the session namespace. A sibling directory leaves that namespace entirely to SessionStore, which is unmodified.
1 parent d7cc07b commit 5e389ce

13 files changed

Lines changed: 206 additions & 170 deletions

‎src/terminal/ControlConsole.cpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ namespace et {
66

77
namespace {
88
const int kDefaultRows = 24;
9-
// 132 columns is the standard "wide" terminal (the VT100/VT220 132-column mode);
10-
// it is a better default than 80 for a session whose viewer hasn't sized it yet,
11-
// since attach/observe only learn the real width once someone connects.
9+
// 132 columns is the standard "wide" terminal (the VT100/VT220 132-column
10+
// mode); it is a better default than 80 for a session whose viewer hasn't sized
11+
// it yet, since attach/observe only learn the real width once someone connects.
1212
const int kDefaultCols = 132;
1313

1414
int64_t nowSeconds() { return (int64_t)time(NULL); }

‎src/terminal/ControlConsole.hpp‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,30 @@ namespace et {
1313
* of from a local TTY. It is the seam that lets the *unmodified*
1414
* TerminalClient::run() loop be controlled by an external tool (etctl):
1515
*
16-
* * getFd() returns the read end of an internal pipe. run() selects on it and
17-
* forwards whatever appears as TERMINAL_BUFFER input, so injectInput() bytes
18-
* (keystrokes, 0x03, 0x04, escape sequences, anything) reach the remote PTY
19-
* exactly as if typed.
16+
* * getFd() returns the read end of an internal pipe. run() selects on it
17+
* and forwards whatever appears as TERMINAL_BUFFER input, so injectInput()
18+
* bytes (keystrokes, 0x03, 0x04, escape sequences, anything) reach the remote
19+
* PTY exactly as if typed.
2020
* * write() is the output sink: run() hands every TERMINAL_BUFFER it receives
2121
* here, and we append the raw bytes to a non-destructive scrollback that
2222
* readOutput() serves by cursor.
23-
* * getTerminalInfo() returns a settable size; setSize() changes it, and run()
24-
* emits a TERMINAL_INFO (resize) on its next poll.
23+
* * getTerminalInfo() returns a settable size; setSize() changes it, and
24+
* run() emits a TERMINAL_INFO (resize) on its next poll.
2525
* * setup()/teardown() are no-ops: there is no TTY to put in raw mode.
2626
*
27-
* The pipe is kernel-synchronized; the scrollback is internally locked; the size
28-
* is guarded here. run() touches this object on its main thread (getFd reads,
29-
* write, getTerminalInfo); the control listener touches it on another thread
30-
* (injectInput, readOutput, setSize) — a clean producer/consumer split.
27+
* The pipe is kernel-synchronized; the scrollback is internally locked; the
28+
* size is guarded here. run() touches this object on its main thread (getFd
29+
* reads, write, getTerminalInfo); the control listener touches it on another
30+
* thread (injectInput, readOutput, setSize) — a clean producer/consumer split.
3131
*/
3232
class ControlConsole : public Console {
3333
public:
34-
explicit ControlConsole(size_t scrollbackCapBytes =
35-
SessionScrollback::kDefaultCapBytes);
34+
explicit ControlConsole(
35+
size_t scrollbackCapBytes = SessionScrollback::kDefaultCapBytes);
3636
virtual ~ControlConsole();
3737

38-
// --- Console interface (called by TerminalClient::run on its main thread) ---
38+
// --- Console interface (called by TerminalClient::run on its main thread)
39+
// ---
3940
std::optional<TerminalInfo> getTerminalInfo() override;
4041
virtual void setup() {}
4142
virtual void teardown() {}

‎src/terminal/ControlListener.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
// Make struct ucred / SO_PEERCRED visible on glibc (Linux peer-credential check).
1+
// Make struct ucred / SO_PEERCRED visible on glibc (Linux peer-credential
2+
// check).
23
#if defined(__linux__) && !defined(_GNU_SOURCE)
34
#define _GNU_SOURCE
45
#endif
@@ -104,7 +105,8 @@ bool ControlListener::peerAuthorized(int connFd) const {
104105
// Only the owning user may drive the session, even if perms were loosened.
105106
uid_t peerUid = (uid_t)-1;
106107
#ifdef __linux__
107-
// Linux reports peer credentials through SO_PEERCRED (getpeereid is BSD-only).
108+
// Linux reports peer credentials through SO_PEERCRED (getpeereid is
109+
// BSD-only).
108110
struct ucred cred;
109111
socklen_t len = sizeof(cred);
110112
if (::getsockopt(connFd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0) {

‎src/terminal/ControlListener.hpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ namespace et {
1717
* router bridge, but speaks the (unencrypted, local) control framing.
1818
*
1919
* Connections are short-lived: one request, one response, close. Streaming
20-
* reads (etctl read --follow / observe) are done by the client polling CTL_READ,
21-
* so the daemon keeps no per-reader state.
20+
* reads (etctl read --follow / observe) are done by the client polling
21+
* CTL_READ, so the daemon keeps no per-reader state.
2222
*/
2323
class ControlListener {
2424
public:

‎src/terminal/ControlPaths.hpp‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@
66

77
/*
88
* Local, per-user discovery for control sessions. Backgrounded `et --ctl`
9-
* sessions each own a socket at ~/.et/sessions/<name>.sock; `etctl sessions`
9+
* sessions each own a socket at ~/.et/control/<name>.sock; `etctl sessions`
1010
* enumerates that directory. The directory is created 0700 and the sockets are
1111
* 0600, so another local user can neither see nor open them.
1212
*
13-
* That is the same directory a named session's record lives in, deliberately: a
14-
* session's socket and its saved record are two artifacts of one named thing,
15-
* so they share a directory rather than each inventing their own. The records
16-
* are bare names and the sockets are suffixed, so neither listing sees the
17-
* other's files.
13+
* It is a sibling of the saved-session directory rather than the same one.
14+
* Co-locating them reads well until a name collides: `~/.et/sessions/<name>` is
15+
* one file per session, and a session may legally be named "foo.sock", which is
16+
* exactly where session "foo" wants its socket. Keeping the live control files
17+
* in their own directory leaves the saved-session namespace entirely to
18+
* SessionStore, so neither side has to reserve names from the other.
1819
*/
1920
namespace et {
2021
namespace control_paths {
@@ -36,8 +37,11 @@ inline void mkdirp0700(const string& dir) {
3637
#endif
3738
}
3839

39-
// The directory holding a session's socket, alongside its saved record.
40-
inline string controlDir() { return sessionDirPath(); }
40+
// The directory holding live control sockets, beside the saved-session
41+
// directory rather than inside it.
42+
inline string controlDir() {
43+
return fs::path(sessionDirPath()).parent_path().string() + "/control";
44+
}
4145

4246
// Resolve (and materialize) the control directory.
4347
inline string ensureControlDir() {

‎src/terminal/ControlProtocol.hpp‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,30 @@
1515
* [1 byte opcode][4 byte big-endian payload length][payload bytes]
1616
*
1717
* Requests carry ET's native vocabulary verbatim where one exists: WRITE is a
18-
* raw TERMINAL_BUFFER payload, RESIZE is a TerminalInfo protobuf. READ/INFO/KILL
19-
* are the only additions, for things ET has no native packet for (read-at-cursor,
20-
* liveness, end-session).
18+
* raw TERMINAL_BUFFER payload, RESIZE is a TerminalInfo protobuf.
19+
* READ/INFO/KILL are the only additions, for things ET has no native packet for
20+
* (read-at-cursor, liveness, end-session).
2121
*/
2222
namespace et {
2323

2424
enum ControlOpcode : uint8_t {
2525
// Requests (etctl -> daemon)
2626
CTL_WRITE = 1, // payload: raw input bytes to inject
2727
CTL_RESIZE = 2, // payload: TerminalInfo protobuf
28-
CTL_READ = 3, // payload: 8-byte big-endian int64 cursor (<0 => from oldest)
29-
CTL_INFO = 4, // payload: empty
30-
CTL_KILL = 5, // payload: empty
31-
CTL_SNIFF = 6, // payload: 8-byte int64 record cursor (<0 => from oldest)
32-
CTL_WRITE_SECRET =
33-
7, // payload: raw input bytes; like CTL_WRITE but redacted from transcript
28+
CTL_READ = 3, // payload: 8-byte big-endian int64 cursor (<0 => from oldest)
29+
CTL_INFO = 4, // payload: empty
30+
CTL_KILL = 5, // payload: empty
31+
CTL_SNIFF = 6, // payload: 8-byte int64 record cursor (<0 => from oldest)
32+
CTL_WRITE_SECRET = 7, // payload: raw input bytes; like CTL_WRITE but
33+
// redacted from transcript
3434

3535
// Responses (daemon -> etctl)
3636
CTL_OK = 64, // payload: empty
3737
CTL_ERR = 65, // payload: error message (utf-8)
3838
CTL_READ_RESP = 66, // payload: [8B nextCursor][1B truncated][data...]
3939
CTL_INFO_RESP = 67, // payload: "key=value\n" lines
40-
CTL_SNIFF_RESP = 68, // payload: [8B next][1B trunc][rec: 1B dir,4B len,bytes]*
40+
CTL_SNIFF_RESP =
41+
68, // payload: [8B next][1B trunc][rec: 1B dir,4B len,bytes]*
4142
};
4243

4344
namespace control_proto {

‎src/terminal/SessionScrollback.hpp‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ struct ScrollbackRead {
3232
* already uses in htm's TerminalHandler (a deque<string> buffer plus a byte
3333
* count); the only additions are a monotonic absolute cursor and FIFO eviction.
3434
*
35-
* Cursors are absolute byte offsets from the start of the session. `headOffset`
35+
* Cursors are absolute byte offsets from the start of the session. `headOffset`
3636
* is the offset just past the most recent byte (the live cursor); `baseOffset`
3737
* is the offset of the oldest byte still retained. Both only ever increase.
3838
*/
@@ -87,9 +87,8 @@ class SessionScrollback {
8787
for (const auto& chunk : chunks) {
8888
const int64_t chunkEnd = chunkStart + static_cast<int64_t>(chunk.size());
8989
if (chunkEnd > cursor) {
90-
const size_t from = cursor > chunkStart
91-
? static_cast<size_t>(cursor - chunkStart)
92-
: 0;
90+
const size_t from =
91+
cursor > chunkStart ? static_cast<size_t>(cursor - chunkStart) : 0;
9392
result.data.append(chunk, from, std::string::npos);
9493
}
9594
chunkStart = chunkEnd;

‎src/terminal/SessionTombstone.hpp‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#ifndef __ET_SESSION_TOMBSTONE_HPP__
22
#define __ET_SESSION_TOMBSTONE_HPP__
33

4+
#include "ControlPaths.hpp"
45
#include "Headers.hpp"
56
#include "SessionStore.hpp"
67

@@ -12,8 +13,8 @@
1213
* "you never opened it" or "something removed it". The daemon knows the reason
1314
* at the moment it exits; the tombstone is where it leaves that reason behind.
1415
*
15-
* It lives beside the session's saved record, under the name it ended as, and
16-
* is suffixed so a session listing never mistakes a note for a session.
16+
* It lives with the session's control socket, under the name it ended as, in
17+
* the control directory rather than the saved-session one.
1718
*/
1819
namespace et {
1920
namespace session_tombstone {
@@ -22,13 +23,14 @@ inline string pathForName(const string& name) {
2223
if (!isValidSessionName(name)) {
2324
throw std::runtime_error("invalid session name '" + name + "'");
2425
}
25-
return sessionDirPath() + "/" + name + ".gone";
26+
return control_paths::controlDir() + "/" + name + ".gone";
2627
}
2728

2829
// Record why a session ended. Best effort: a session that cannot leave a note
2930
// is no worse off than one that never wrote one.
3031
inline void write(const string& name, const string& reason) {
3132
try {
33+
control_paths::ensureControlDir();
3234
std::ofstream out(pathForName(name), std::ios::trunc);
3335
if (!out) {
3436
return;

‎src/terminal/SessionTranscript.hpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ struct TranscriptRead {
3333
* A bounded, ordered log of both injected input and produced output, used by
3434
* `etctl peep` to tap the byte exchange (» sent / « received). Records are
3535
* appended in processing order and addressed by a monotonic record index; the
36-
* ring evicts oldest records once a byte cap is exceeded. This is separate from
37-
* the SessionScrollback (which is output-only and serves `read`), so peep can
38-
* show the two directions interleaved without disturbing reads.
36+
* ring evicts oldest records once a byte cap is exceeded. This is separate
37+
* from the SessionScrollback (which is output-only and serves `read`), so peep
38+
* can show the two directions interleaved without disturbing reads.
3939
*/
4040
class SessionTranscript {
4141
public:

‎src/terminal/TerminalClient.hpp‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,7 @@ class TerminalClient {
157157
// flips this to false during a drop and back to true once it reconnects, so
158158
// it distinguishes "link down" from "the daemon is gone" (the latter shows as
159159
// an unreachable control socket).
160-
bool isConnected() {
161-
return connection && !connection->isDisconnected();
162-
}
160+
bool isConnected() { return connection && !connection->isDisconnected(); }
163161

164162
protected:
165163
/** @brief Console wrapper used for local terminal input/output. */

0 commit comments

Comments
 (0)