diff --git a/internal/tools/docker_exec.go b/internal/tools/docker_exec.go index ea741c8..705e3f1 100644 --- a/internal/tools/docker_exec.go +++ b/internal/tools/docker_exec.go @@ -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 diff --git a/internal/web/pty.go b/internal/web/pty.go index d3de43f..f79de7d 100644 --- a/internal/web/pty.go +++ b/internal/web/pty.go @@ -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 @@ -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, @@ -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 @@ -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) } @@ -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 } diff --git a/internal/web/server.go b/internal/web/server.go index 96a7b49..aaee9ff 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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) } diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index fd7bebd..59bb525 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -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 接続', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 9410821..1acf46a 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -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 연결', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index cc49de3..b9cdaf9 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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 连接', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 7f137e0..6867140 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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 連線',