A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sophia): deliver Ctrl-D EOF to foreground commands

The previous pipe layout shared one stdin pipe between term.Terminal
(prompt) and the foreground command. That conflated two lifetimes:
closing the pipe to deliver EOF to a wasm guest on Ctrl-D would also
take down the prompt, and not closing it left REPLs unable to exit
their inner read loop.

Split it into two pipes. The prompt pipe is long-lived — term.Terminal
reads it for the entire SSH session. The command pipe is rotated each
sh.Run: a fresh os.Pipe before the run, handed to the runner via
interp.StdIO, closed and dropped after.

The SSH-input pump now routes bytes by mode (gated on the existing
commandActive atomic): prompt pipe while idle, command pipe while a
command is foreground. While in command mode, it scans for VEOF
(Ctrl-D, 0x04) — the same byte a kernel PTY recognises — and on hit,
flushes anything before it, then closes the command's stdin so the
guest's next fd_read returns 0. A small mutex guards the rotating
write handle so the pump can race-safely close it from its goroutine
while the main loop swaps in the next pipe.

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 31da08c0 4421e350

+86 -16
+86 -16
cmd/sophia/main.go
··· 8 8 "log" 9 9 "log/slog" 10 10 "os" 11 + "sync" 11 12 "sync/atomic" 12 13 "time" 13 14 ··· 129 130 // wazero down its non-*os.File path. That path reports stdio as 130 131 // FILETYPE_BLOCK_DEVICE to WASI guests and trips up wasi-libc's 131 132 // isatty/buffering detection in python.wasm and qjs.wasm. 132 - stdinR, stdinW, err := os.Pipe() 133 + // 134 + // Two pipes for input: a long-lived prompt pipe that term.Terminal 135 + // reads from, and a per-command pipe (rotated each sh.Run) so we can 136 + // close it on Ctrl-D to deliver EOF to the foreground command without 137 + // taking down the shell prompt. 138 + promptR, promptW, err := os.Pipe() 133 139 if err != nil { 134 - return fmt.Errorf("can't open stdin pipe: %w", err) 140 + return fmt.Errorf("can't open prompt pipe: %w", err) 135 141 } 136 - defer stdinR.Close() 137 - defer stdinW.Close() 142 + defer promptR.Close() 143 + defer promptW.Close() 138 144 139 145 stdoutR, stdoutW, err := os.Pipe() 140 146 if err != nil { ··· 155 161 // already echoes during the prompt, so we only echo during command mode. 156 162 var commandActive atomic.Bool 157 163 158 - // Pump SSH client bytes into the shared stdin pipe. Both term.Terminal 159 - // (for the prompt) and running wasm commands read from stdinR; they 160 - // alternate in time, so a single pump is race-free. 164 + // cmdStdinW is the write end of the foreground command's stdin pipe. 165 + // It rotates each sh.Run; the pump writes typed bytes here while 166 + // commandActive is true, and closes it (signalling EOF to the wasm 167 + // guest) when the user presses Ctrl-D. 168 + var ( 169 + cmdMu sync.Mutex 170 + cmdStdinW *os.File 171 + ) 172 + 173 + // Pump SSH client bytes into either the prompt pipe (when idle) or the 174 + // foreground command's stdin pipe (while a command runs). 161 175 // 162 - // Two pieces of line discipline that a kernel PTY would normally do for 163 - // us, done here in software: 176 + // Three pieces of line discipline that a kernel PTY would normally do 177 + // for us, done here in software: 164 178 // - ICRNL: translate \r (the Enter key on a raw SSH channel) to \n, 165 179 // so line-mode WASI readers like Python's fgets recognize Enter. 166 180 // term.Terminal accepts either, so the prompt is unaffected. 167 181 // - ECHO: while a command is running, echo typed bytes back to the 168 182 // SSH client so REPLs (qjs, python -i) aren't typing blind. 183 + // - VEOF (Ctrl-D, 0x04): forward bytes up to the Ctrl-D and then 184 + // close the foreground command's stdin so its next fd_read returns 185 + // EOF. 169 186 go func() { 170 - defer stdinW.Close() 187 + defer promptW.Close() 188 + defer func() { 189 + cmdMu.Lock() 190 + if cmdStdinW != nil { 191 + cmdStdinW.Close() 192 + cmdStdinW = nil 193 + } 194 + cmdMu.Unlock() 195 + }() 171 196 buf := make([]byte, 4096) 172 197 for { 173 198 n, err := sess.Read(buf) ··· 177 202 buf[i] = '\n' 178 203 } 179 204 } 205 + 180 206 if commandActive.Load() { 181 - echoLineDiscipline(sess, buf[:n]) 182 - } 183 - if _, werr := stdinW.Write(buf[:n]); werr != nil { 184 - return 207 + eofAt := -1 208 + for i, b := range buf[:n] { 209 + if b == 0x04 { 210 + eofAt = i 211 + break 212 + } 213 + } 214 + end := n 215 + if eofAt >= 0 { 216 + end = eofAt 217 + } 218 + if end > 0 { 219 + echoLineDiscipline(sess, buf[:end]) 220 + } 221 + cmdMu.Lock() 222 + if cmdStdinW != nil { 223 + if end > 0 { 224 + cmdStdinW.Write(buf[:end]) 225 + } 226 + if eofAt >= 0 { 227 + cmdStdinW.Close() 228 + cmdStdinW = nil 229 + } 230 + } 231 + cmdMu.Unlock() 232 + } else { 233 + if _, werr := promptW.Write(buf[:n]); werr != nil { 234 + return 235 + } 185 236 } 186 237 } 187 238 if err != nil { ··· 192 243 193 244 // Drain guest stdout/stderr into the terminal, which handles \n→\r\n 194 245 // translation in writeWithCRLF. 195 - t := term.NewTerminal(sessRW{r: stdinR, w: sess}, "$ ") 246 + t := term.NewTerminal(sessRW{r: promptR, w: sess}, "$ ") 196 247 go io.Copy(t, stdoutR) 197 248 go io.Copy(t, stderrR) 198 249 ··· 220 271 sh, err = interp.New( 221 272 interp.Interactive(true), 222 273 interp.Env(env), 223 - interp.StdIO(stdinR, stdoutW, stderrW), 274 + interp.StdIO(nil, stdoutW, stderrW), 224 275 interp.ExecHandlers(middleware), 225 276 interp.CallHandler(billysh.CallHandler(s.reg, fsys, os.Stdout, os.Stderr)), 226 277 interp.StatHandler(billysh.FsysStatHandler(s.reg, fsys)), ··· 250 301 251 302 ctx, cancel := context.WithTimeout(context.Background(), *timeout) 252 303 for _, stmt := range stmts { 304 + cmdR, cmdW, perr := os.Pipe() 305 + if perr != nil { 306 + fmt.Fprintln(t, "stdin pipe:", perr) 307 + continue 308 + } 309 + cmdMu.Lock() 310 + cmdStdinW = cmdW 311 + cmdMu.Unlock() 312 + interp.StdIO(cmdR, stdoutW, stderrW)(sh) 313 + 253 314 commandActive.Store(true) 254 315 runErr := sh.Run(ctx, stmt) 255 316 commandActive.Store(false) 317 + 318 + cmdMu.Lock() 319 + if cmdStdinW != nil { 320 + cmdStdinW.Close() 321 + cmdStdinW = nil 322 + } 323 + cmdMu.Unlock() 324 + cmdR.Close() 325 + 256 326 if sh.Exited() { 257 327 cancel() 258 328 return runErr