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
14 changes: 12 additions & 2 deletions internal/tools/docker_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,19 +296,29 @@ func (d *DockerExecutor) run(ctx context.Context, command string, timeout time.D
copyDone <- e
}()

var copyErr error
select {
case <-copyDone:
case copyErr = <-copyDone:
// stream drained: command finished
case <-ctx.Done():
att.Close() // unblock the StdCopy goroutine
<-copyDone
return stdout.String(), stderr.String(), fmt.Errorf("command timed out or cancelled: %w", ctx.Err())
}
// A non-nil StdCopy error means the stream broke (daemon error frame, short
// write, decode failure). Surface it instead of returning a truncated result
// as success — callers (ReadFile/Exec → edit) would otherwise persist it.
if copyErr != nil {
return stdout.String(), stderr.String(), fmt.Errorf("docker exec stream copy: %w", copyErr)
}

// Use a fresh context for the inspect: the exec ctx may already be at its
// deadline, but the exec itself completed and its exit code is available.
inspect, ierr := d.cli.ContainerExecInspect(context.Background(), resp.ID)
if ierr == nil && inspect.ExitCode != 0 {
if ierr != nil {
return stdout.String(), stderr.String(), fmt.Errorf("docker exec inspect: %w", ierr)
}
if inspect.ExitCode != 0 {
return stdout.String(), stderr.String(), fmt.Errorf("command exited with code %d", inspect.ExitCode)
}
return stdout.String(), stderr.String(), nil
Expand Down
33 changes: 29 additions & 4 deletions internal/web/pty.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/gorilla/websocket"

"github.com/cnjack/jcode/internal/config"
"github.com/cnjack/jcode/internal/tools"
)

// ptyBackend abstracts the transport behind a terminal session: a local PTY, or
Expand Down Expand Up @@ -99,10 +100,26 @@ func (m *ptyManager) create(workDir, ownerID string) (string, error) {
return id, nil
}

// createDocker starts a TTY `docker exec` session inside containerID and returns
// its ID. The shell is bash if present, otherwise sh.
func (m *ptyManager) createDocker(cli *client.Client, containerID, workDir, ownerID string) (string, error) {
// createDocker starts a TTY `docker exec` session inside the container and
// returns its ID. The shell is bash if present, otherwise sh.
//
// The terminal holds its OWN container reference for its lifetime (via
// AcquireDockerContainer): a concurrent env switch releases the engine's
// reference, and without an independent one the last release would stop the
// container out from under a live terminal. For a container the user already had
// running this takes no reference and the matching Close() is a harmless no-op.
func (m *ptyManager) createDocker(containerRef, workDir, ownerID string) (string, error) {
ctx := context.Background()
exec, err := tools.AcquireDockerContainer(ctx, containerRef)
if err != nil {
return "", err
}
cli, err := tools.DockerClient()
if err != nil {
_ = exec.Close()
return "", err
}
containerID := exec.ContainerID()
resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: []string{"sh", "-c", "exec bash 2>/dev/null || exec sh"},
WorkingDir: workDir,
Expand All @@ -113,14 +130,16 @@ func (m *ptyManager) createDocker(cli *client.Client, containerID, workDir, owne
AttachStderr: true,
})
if err != nil {
_ = exec.Close()
return "", err
}
att, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{Tty: true})
if err != nil {
_ = exec.Close()
return "", err
}

backend := &dockerPTYBackend{cli: cli, execID: resp.ID, att: att}
backend := &dockerPTYBackend{cli: cli, execID: resp.ID, att: att, exec: exec}
id := m.register(ownerID, backend)

// Clean up when the exec process exits. Nothing else waits on it, so poll
Expand Down Expand Up @@ -306,6 +325,7 @@ type dockerPTYBackend struct {
cli *client.Client
execID string
att dockertypes.HijackedResponse
exec *tools.DockerExecutor // owns a container ref for the terminal's lifetime
}

func (b *dockerPTYBackend) Read(p []byte) (int, error) { return b.att.Reader.Read(p) }
Expand All @@ -318,6 +338,11 @@ func (b *dockerPTYBackend) Resize(cols, rows uint16) error {
}
func (b *dockerPTYBackend) Close() error {
b.att.Close()
if b.exec != nil {
// Release this terminal's container ref; on the last release of a
// container we started, this stops it.
_ = b.exec.Close()
}
return nil
}

Expand Down
9 changes: 3 additions & 6 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2264,12 +2264,9 @@ func (s *Server) handleCreatePTY(w http.ResponseWriter, r *http.Request) {
err error
)
if dockerExec != nil {
cli, derr := tools.DockerClient()
if derr != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": derr.Error()})
return
}
id, err = s.ptyMgr.createDocker(cli, dockerExec.ContainerID(), pwd, owner)
// createDocker acquires its own container ref (so an env switch can't stop
// the container under a live terminal) and resolves the shared client itself.
id, err = s.ptyMgr.createDocker(dockerExec.ContainerID(), pwd, owner)
} else {
id, err = s.ptyMgr.create(pwd, owner)
}
Expand Down
8 changes: 8 additions & 0 deletions web/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,14 @@ export default {
chooseMethod: '接続方法を選択',
ssh: 'SSH',
docker: 'Docker',
container: 'コンテナ',
dockerConnection: 'Docker コンテナ',
dockerDesc: 'ワークスペースとして使用するコンテナを選択してください。停止中のコンテナは自動的に起動され、どのタスクも使用していないときに再び停止されます。',
selectContainer: 'コンテナを選択',
noContainers: 'この Docker ホストにコンテナが見つかりません。',
refresh: '更新',
running: '実行中',
stopped: '停止中',
remoteHost: 'リモートホスト',
comingSoon: '近日対応',
sshConnection: 'SSH 接続',
Expand Down
8 changes: 8 additions & 0 deletions web/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,14 @@ export default {
chooseMethod: '연결 방법 선택',
ssh: 'SSH',
docker: 'Docker',
container: '컨테이너',
dockerConnection: 'Docker 컨테이너',
dockerDesc: '워크스페이스로 사용할 컨테이너를 선택하세요. 중지된 컨테이너는 자동으로 시작되며, 사용 중인 작업이 없으면 다시 중지됩니다.',
selectContainer: '컨테이너 선택',
noContainers: '이 Docker 호스트에서 컨테이너를 찾을 수 없습니다.',
refresh: '새로 고침',
running: '실행 중',
stopped: '중지됨',
remoteHost: '원격 호스트',
comingSoon: '준비 중',
sshConnection: 'SSH 연결',
Expand Down
8 changes: 8 additions & 0 deletions web/src/i18n/locales/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,14 @@ export default {
chooseMethod: '选择连接方式',
ssh: 'SSH',
docker: 'Docker',
container: '容器',
dockerConnection: 'Docker 容器',
dockerDesc: '选择一个容器作为工作区。已停止的容器会自动为你启动,在没有任务使用时再次停止。',
selectContainer: '选择一个容器',
noContainers: '此 Docker 主机上未找到容器。',
refresh: '刷新',
running: '运行中',
stopped: '已停止',
remoteHost: '远程主机',
comingSoon: '即将推出',
sshConnection: 'SSH 连接',
Expand Down
8 changes: 8 additions & 0 deletions web/src/i18n/locales/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,14 @@ export default {
chooseMethod: '選擇連線方式',
ssh: 'SSH',
docker: 'Docker',
container: '容器',
dockerConnection: 'Docker 容器',
dockerDesc: '選擇一個容器作為工作區。已停止的容器會自動為你啟動,在沒有任務使用時再次停止。',
selectContainer: '選擇一個容器',
noContainers: '此 Docker 主機上未找到容器。',
refresh: '重新整理',
running: '執行中',
stopped: '已停止',
remoteHost: '遠端主機',
comingSoon: '即將推出',
sshConnection: 'SSH 連線',
Expand Down
Loading